fix: improve material search selectors
This commit is contained in:
parent
bb195610fd
commit
368c0bfb99
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
54
frontend/src/stores/materials.test.js
Normal file
54
frontend/src/stores/materials.test.js
Normal 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: '早期物料'
|
||||
})
|
||||
})
|
||||
})
|
||||
36
frontend/src/utils/materialSearch.js
Normal file
36
frontend/src/utils/materialSearch.js
Normal 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)
|
||||
}
|
||||
33
frontend/src/utils/materialSearch.test.js
Normal file
33
frontend/src/utils/materialSearch.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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} 件)`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user