Compare commits
6 Commits
4905e60f8c
...
688dffebaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688dffebaa | ||
|
|
b9c0681c47 | ||
|
|
43f680219e | ||
|
|
bcee877278 | ||
|
|
fbd0bc1341 | ||
|
|
bf7f7129dc |
|
|
@ -7,7 +7,12 @@ export const useApplicationStore = defineStore('application', () => {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const myPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
const myPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||||||
|
|
||||||
|
// 列表当前生效的筛选条件。取消申请后会在原地重新拉取列表,那次拉取必须沿用
|
||||||
|
// 用户选中的状态,否则列表会悄悄退回全量,而下拉框里还显示着选中的筛选值。
|
||||||
|
const filters = ref({ status: '' })
|
||||||
|
|
||||||
async function fetchMyApplications(status = '', pageNum = 1) {
|
async function fetchMyApplications(status = '', pageNum = 1) {
|
||||||
|
filters.value = { status }
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await applicationApi.getMy(status, pageNum, myPage.value.pageSize)
|
const res = await applicationApi.getMy(status, pageNum, myPage.value.pageSize)
|
||||||
|
|
@ -22,13 +27,16 @@ export const useApplicationStore = defineStore('application', () => {
|
||||||
|
|
||||||
async function createApplication(data) {
|
async function createApplication(data) {
|
||||||
const res = await applicationApi.create(data)
|
const res = await applicationApi.create(data)
|
||||||
|
// 这处刻意清空筛选并回到第 1 页,和上面「沿用筛选」的规矩相反:刚提交的是
|
||||||
|
// 待审批单,若沿用「已通过」之类的筛选就看不见它,用户会以为没提交成功。
|
||||||
|
// 清空的是 store 这份状态,页面那份(下拉框)由 Application.vue 同步重置。
|
||||||
await fetchMyApplications('', 1)
|
await fetchMyApplications('', 1)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelApplication(id) {
|
async function cancelApplication(id) {
|
||||||
const res = await applicationApi.cancel(id)
|
const res = await applicationApi.cancel(id)
|
||||||
await fetchMyApplications('', myPage.value.currentPage)
|
await fetchMyApplications(filters.value.status, myPage.value.currentPage)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
63
frontend/src/stores/application.test.js
Normal file
63
frontend/src/stores/application.test.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { applicationApi } from '../api/application'
|
||||||
|
import { useApplicationStore } from './application'
|
||||||
|
|
||||||
|
vi.mock('../api/application', () => ({
|
||||||
|
applicationApi: {
|
||||||
|
getAll: vi.fn(),
|
||||||
|
getMy: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
approve: vi.fn(),
|
||||||
|
reject: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('application store 的筛选保持', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
applicationApi.getMy.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
applicationApi.create.mockResolvedValue({ data: {} })
|
||||||
|
applicationApi.cancel.mockResolvedValue({ data: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 取消申请会在原地重新拉取列表,那次拉取必须沿用用户选中的状态:否则列表会悄悄
|
||||||
|
// 退回全量(已通过/已拒绝的单子全冒出来),而下拉框里还显示着「待审批」。
|
||||||
|
it('取消申请后刷新列表要沿用当前的筛选条件', async () => {
|
||||||
|
const store = useApplicationStore()
|
||||||
|
|
||||||
|
await store.fetchMyApplications('pending', 2)
|
||||||
|
applicationApi.getMy.mockClear()
|
||||||
|
|
||||||
|
await store.cancelApplication(3)
|
||||||
|
|
||||||
|
expect(applicationApi.getMy).toHaveBeenCalledWith('pending', 2, 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 这处刻意与上面相反:刚提交的是待审批单,若沿用「已通过」之类的筛选就看不见它,
|
||||||
|
// 用户会以为没提交成功。所以清空筛选并回到第 1 页,让新单排在列表最前面。
|
||||||
|
it('提交新申请后要清空筛选并回到第 1 页', async () => {
|
||||||
|
const store = useApplicationStore()
|
||||||
|
|
||||||
|
await store.fetchMyApplications('approved', 3)
|
||||||
|
applicationApi.getMy.mockClear()
|
||||||
|
|
||||||
|
await store.createApplication({ purpose: '测试' })
|
||||||
|
|
||||||
|
expect(applicationApi.getMy).toHaveBeenCalledWith('', 1, 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。
|
||||||
|
it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => {
|
||||||
|
const store = useApplicationStore()
|
||||||
|
|
||||||
|
await store.fetchMyApplications('rejected', 1)
|
||||||
|
applicationApi.getMy.mockClear()
|
||||||
|
|
||||||
|
await store.fetchMyApplications()
|
||||||
|
|
||||||
|
expect(applicationApi.getMy).toHaveBeenCalledWith('', 1, 20)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -7,7 +7,12 @@ export const useApprovalStore = defineStore('approval', () => {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||||||
|
|
||||||
|
// 列表当前生效的筛选条件。审批通过/拒绝后会在原地重新拉取列表,那次拉取必须
|
||||||
|
// 沿用用户选中的状态,否则列表会悄悄退回全量,而下拉框里还显示着选中的筛选值。
|
||||||
|
const filters = ref({ status: '' })
|
||||||
|
|
||||||
async function fetchPendingApplications(status = '', pageNum = 1) {
|
async function fetchPendingApplications(status = '', pageNum = 1) {
|
||||||
|
filters.value = { status }
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await applicationApi.getAll(status, pageNum, page.value.pageSize)
|
const res = await applicationApi.getAll(status, pageNum, page.value.pageSize)
|
||||||
|
|
@ -22,13 +27,13 @@ export const useApprovalStore = defineStore('approval', () => {
|
||||||
|
|
||||||
async function approveApplication(id, data = {}) {
|
async function approveApplication(id, data = {}) {
|
||||||
const res = await applicationApi.approve(id, data)
|
const res = await applicationApi.approve(id, data)
|
||||||
await fetchPendingApplications('', page.value.currentPage)
|
await fetchPendingApplications(filters.value.status, page.value.currentPage)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rejectApplication(id, data = {}) {
|
async function rejectApplication(id, data = {}) {
|
||||||
const res = await applicationApi.reject(id, data)
|
const res = await applicationApi.reject(id, data)
|
||||||
await fetchPendingApplications('', page.value.currentPage)
|
await fetchPendingApplications(filters.value.status, page.value.currentPage)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
70
frontend/src/stores/approval.test.js
Normal file
70
frontend/src/stores/approval.test.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { applicationApi } from '../api/application'
|
||||||
|
import { useApprovalStore } from './approval'
|
||||||
|
|
||||||
|
vi.mock('../api/application', () => ({
|
||||||
|
applicationApi: {
|
||||||
|
getAll: vi.fn(),
|
||||||
|
getMy: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
approve: vi.fn(),
|
||||||
|
reject: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('approval store 的筛选保持', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
applicationApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
applicationApi.approve.mockResolvedValue({ data: {} })
|
||||||
|
applicationApi.reject.mockResolvedValue({ data: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 审批会在原地重新拉取列表,那次拉取必须沿用用户选中的状态:否则列表会悄悄退回
|
||||||
|
// 全量(已通过/已拒绝的单子全冒出来),而下拉框里还显示着「待处理」。
|
||||||
|
it('审批通过后刷新列表要沿用当前的筛选条件', async () => {
|
||||||
|
const store = useApprovalStore()
|
||||||
|
|
||||||
|
await store.fetchPendingApplications('pending', 2)
|
||||||
|
applicationApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.approveApplication(9, { auto_outbound: true })
|
||||||
|
|
||||||
|
expect(applicationApi.getAll).toHaveBeenCalledWith('pending', 2, 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('审批拒绝后刷新列表要沿用当前的筛选条件', async () => {
|
||||||
|
const store = useApprovalStore()
|
||||||
|
|
||||||
|
await store.fetchPendingApplications('pending', 1)
|
||||||
|
applicationApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.rejectApplication(9, { reason: '库存不足' })
|
||||||
|
|
||||||
|
expect(applicationApi.getAll).toHaveBeenCalledWith('pending', 1, 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。
|
||||||
|
it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => {
|
||||||
|
const store = useApprovalStore()
|
||||||
|
|
||||||
|
await store.fetchPendingApplications('approved', 1)
|
||||||
|
applicationApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.fetchPendingApplications()
|
||||||
|
|
||||||
|
expect(applicationApi.getAll).toHaveBeenCalledWith('', 1, 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 翻页本身也要带上筛选:漏掉的话第 2 页会突然冒出全量数据。
|
||||||
|
it('翻页时带上当前的筛选条件', async () => {
|
||||||
|
const store = useApprovalStore()
|
||||||
|
|
||||||
|
await store.fetchPendingApplications('approved', 3)
|
||||||
|
|
||||||
|
expect(applicationApi.getAll).toHaveBeenCalledWith('approved', 3, 20)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -12,6 +12,11 @@ export const useMaterialsStore = defineStore('materials', () => {
|
||||||
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 })
|
||||||
|
|
||||||
|
// 列表当前生效的筛选条件。编辑/作废/恢复后会在原地重新拉取列表,那次拉取必须沿用
|
||||||
|
// 它们,否则列表会悄悄退回全量,而搜索框里还显示着用户输入的关键字——用户会把
|
||||||
|
// 全量列表当成筛选结果,接着的作废操作就打在误认的那一条上。
|
||||||
|
const filters = ref({ search: '', status: 'all' })
|
||||||
|
|
||||||
const categoryMap = {
|
const categoryMap = {
|
||||||
'A01': '标准件', 'A02': '成品件', 'A03': '简易加工件', 'A04': '精密加工件',
|
'A01': '标准件', 'A02': '成品件', 'A03': '简易加工件', 'A04': '精密加工件',
|
||||||
'A05': '钣金件', 'A06': '塑胶件', 'A07': '其他',
|
'A05': '钣金件', 'A06': '塑胶件', 'A07': '其他',
|
||||||
|
|
@ -30,6 +35,7 @@ export const useMaterialsStore = defineStore('materials', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMaterials(page = 1, search = '', status = 'all') {
|
async function fetchMaterials(page = 1, search = '', status = 'all') {
|
||||||
|
filters.value = { search, status }
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const pageSize = pagination.value.pageSize
|
const pageSize = pagination.value.pageSize
|
||||||
|
|
@ -76,19 +82,23 @@ export const useMaterialsStore = defineStore('materials', () => {
|
||||||
|
|
||||||
async function createMaterial(data) {
|
async function createMaterial(data) {
|
||||||
const res = await materialsApi.create(data)
|
const res = await materialsApi.create(data)
|
||||||
await fetchMaterials(1)
|
// 这处刻意清空筛选并回到第 1 页,和下面「沿用筛选」的规矩相反:新建的物料不一定
|
||||||
|
// 匹配当前关键字,沿用搜索就看不见它,用户会以为没建成功。列表按 id 倒序,回到
|
||||||
|
// 第 1 页正好能看见它。清空的是 store 这份状态,页面那份(搜索框)由 Materials.vue
|
||||||
|
// 同步重置。
|
||||||
|
await fetchMaterials(1, '', 'all')
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateMaterial(id, data) {
|
async function updateMaterial(id, data) {
|
||||||
const res = await materialsApi.update(id, data)
|
const res = await materialsApi.update(id, data)
|
||||||
await fetchMaterials(pagination.value.currentPage)
|
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
async function obsoleteMaterial(id) {
|
async function obsoleteMaterial(id) {
|
||||||
const res = await materialsApi.obsolete(id)
|
const res = await materialsApi.obsolete(id)
|
||||||
await fetchMaterials(pagination.value.currentPage)
|
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
|
||||||
// 作废会改变物料在「有效」和「全部」两份选项里的归属,两份都要刷新
|
// 作废会改变物料在「有效」和「全部」两份选项里的归属,两份都要刷新
|
||||||
await refreshMaterialOptions()
|
await refreshMaterialOptions()
|
||||||
return res
|
return res
|
||||||
|
|
@ -96,7 +106,7 @@ export const useMaterialsStore = defineStore('materials', () => {
|
||||||
|
|
||||||
async function restoreMaterial(id) {
|
async function restoreMaterial(id) {
|
||||||
const res = await materialsApi.restore(id)
|
const res = await materialsApi.restore(id)
|
||||||
await fetchMaterials(pagination.value.currentPage)
|
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
|
||||||
await refreshMaterialOptions()
|
await refreshMaterialOptions()
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@ import { useMaterialsStore } from './materials'
|
||||||
|
|
||||||
vi.mock('../api/materials', () => ({
|
vi.mock('../api/materials', () => ({
|
||||||
materialsApi: {
|
materialsApi: {
|
||||||
getAll: vi.fn()
|
getAll: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
obsolete: vi.fn(),
|
||||||
|
restore: vi.fn()
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -74,3 +78,120 @@ describe('materials store selection options', () => {
|
||||||
expect(store.materialOptions).toEqual([])
|
expect(store.materialOptions).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('materials store 的筛选保持', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
materialsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
materialsApi.create.mockResolvedValue({ data: {} })
|
||||||
|
materialsApi.update.mockResolvedValue({ data: {} })
|
||||||
|
materialsApi.obsolete.mockResolvedValue({ data: {} })
|
||||||
|
materialsApi.restore.mockResolvedValue({ data: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 编辑/作废/恢复都在原地重新拉取列表,那次拉取必须沿用搜索框里的关键字:否则列表
|
||||||
|
// 会悄悄退回全量,而搜索框里还显示着用户输入的内容——用户会把全量列表当成筛选结果,
|
||||||
|
// 接着的作废操作就打在误认的那一条上。
|
||||||
|
it('编辑物料后刷新列表要沿用当前的搜索关键字', async () => {
|
||||||
|
const store = useMaterialsStore()
|
||||||
|
|
||||||
|
await store.fetchMaterials(2, '螺丝')
|
||||||
|
materialsApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.updateMaterial(7, { name: '内六角螺丝' })
|
||||||
|
|
||||||
|
expect(materialsApi.getAll).toHaveBeenCalledWith({
|
||||||
|
search: '螺丝',
|
||||||
|
status: 'all',
|
||||||
|
limit: 20,
|
||||||
|
offset: 20
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 作废和恢复除了列表还要刷两份选项,所以 getAll 会被调用多次,只看第一次(列表那次)。
|
||||||
|
it('作废物料后刷新列表要沿用当前的搜索关键字', async () => {
|
||||||
|
const store = useMaterialsStore()
|
||||||
|
|
||||||
|
await store.fetchMaterials(1, '螺丝')
|
||||||
|
materialsApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.obsoleteMaterial(7)
|
||||||
|
|
||||||
|
expect(materialsApi.getAll).toHaveBeenNthCalledWith(1, {
|
||||||
|
search: '螺丝',
|
||||||
|
status: 'all',
|
||||||
|
limit: 20,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('恢复物料后刷新列表要沿用当前的搜索关键字', async () => {
|
||||||
|
const store = useMaterialsStore()
|
||||||
|
|
||||||
|
await store.fetchMaterials(1, '螺丝')
|
||||||
|
materialsApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.restoreMaterial(7)
|
||||||
|
|
||||||
|
expect(materialsApi.getAll).toHaveBeenNthCalledWith(1, {
|
||||||
|
search: '螺丝',
|
||||||
|
status: 'all',
|
||||||
|
limit: 20,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 状态筛选目前还没有页面在用,但 store 的接口本来就有这一维,原地刷新时漏掉它会
|
||||||
|
// 埋下同一个坑:以后页面加上状态下拉,列表又会悄悄退回全量。
|
||||||
|
it('刷新时一并沿用当前的状态筛选', async () => {
|
||||||
|
const store = useMaterialsStore()
|
||||||
|
|
||||||
|
await store.fetchMaterials(1, '', 'obsolete')
|
||||||
|
materialsApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.updateMaterial(7, { name: '内六角螺丝' })
|
||||||
|
|
||||||
|
expect(materialsApi.getAll).toHaveBeenCalledWith({
|
||||||
|
search: '',
|
||||||
|
status: 'obsolete',
|
||||||
|
limit: 20,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 这处刻意与上面相反:新建的物料不一定匹配当前关键字,沿用搜索就看不见它,用户会
|
||||||
|
// 以为没建成功。列表按 id 倒序,回到第 1 页正好能看见它。
|
||||||
|
it('新建物料后要清空筛选并回到第 1 页', async () => {
|
||||||
|
const store = useMaterialsStore()
|
||||||
|
|
||||||
|
await store.fetchMaterials(3, '螺丝', 'active')
|
||||||
|
materialsApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.createMaterial({ name: '新物料' })
|
||||||
|
|
||||||
|
expect(materialsApi.getAll).toHaveBeenCalledWith({
|
||||||
|
search: '',
|
||||||
|
status: 'all',
|
||||||
|
limit: 20,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。
|
||||||
|
it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => {
|
||||||
|
const store = useMaterialsStore()
|
||||||
|
|
||||||
|
await store.fetchMaterials(2, '螺丝', 'obsolete')
|
||||||
|
materialsApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.fetchMaterials()
|
||||||
|
|
||||||
|
expect(materialsApi.getAll).toHaveBeenCalledWith({
|
||||||
|
search: '',
|
||||||
|
status: 'all',
|
||||||
|
limit: 20,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,13 @@ export const useUsersStore = defineStore('users', () => {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||||||
|
|
||||||
|
// 列表当前生效的筛选条件。编辑/删除后会在原地重新拉取列表,那次拉取必须沿用它们,
|
||||||
|
// 否则列表会悄悄退回全量,而搜索框里还显示着用户输入的关键字——用户会把全量列表
|
||||||
|
// 当成筛选结果,接着的删除操作就打在误认的那一条上。
|
||||||
|
const filters = ref({ search: '' })
|
||||||
|
|
||||||
async function fetchUsers(pageNum = 1, search = '') {
|
async function fetchUsers(pageNum = 1, search = '') {
|
||||||
|
filters.value = { search }
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const pageSize = page.value.pageSize
|
const pageSize = page.value.pageSize
|
||||||
|
|
@ -24,19 +30,22 @@ export const useUsersStore = defineStore('users', () => {
|
||||||
|
|
||||||
async function createUser(data) {
|
async function createUser(data) {
|
||||||
const res = await usersApi.create(data)
|
const res = await usersApi.create(data)
|
||||||
await fetchUsers(1)
|
// 这处刻意清空筛选并回到第 1 页,和下面「沿用筛选」的规矩相反:新建的用户不一定
|
||||||
|
// 匹配当前关键字,沿用搜索就看不见它,用户会以为没建成功。清空的是 store 这份
|
||||||
|
// 状态,页面那份(搜索框)由 Users.vue 同步重置。
|
||||||
|
await fetchUsers(1, '')
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateUser(id, data) {
|
async function updateUser(id, data) {
|
||||||
const res = await usersApi.update(id, data)
|
const res = await usersApi.update(id, data)
|
||||||
await fetchUsers(page.value.currentPage)
|
await fetchUsers(page.value.currentPage, filters.value.search)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(id) {
|
async function deleteUser(id) {
|
||||||
const res = await usersApi.delete(id)
|
const res = await usersApi.delete(id)
|
||||||
await fetchUsers(page.value.currentPage)
|
await fetchUsers(page.value.currentPage, filters.value.search)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
90
frontend/src/stores/users.test.js
Normal file
90
frontend/src/stores/users.test.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { usersApi } from '../api/users'
|
||||||
|
import { useUsersStore } from './users'
|
||||||
|
|
||||||
|
vi.mock('../api/users', () => ({
|
||||||
|
usersApi: {
|
||||||
|
getAll: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('users store 的筛选保持', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
usersApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
usersApi.create.mockResolvedValue({ data: {} })
|
||||||
|
usersApi.update.mockResolvedValue({ data: {} })
|
||||||
|
usersApi.delete.mockResolvedValue({ data: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 编辑/删除都在原地重新拉取列表,那次拉取必须沿用搜索框里的用户名:否则列表会悄悄
|
||||||
|
// 退回全量,而搜索框里还显示着用户输入的内容——用户会把全量列表当成筛选结果,接着
|
||||||
|
// 的删除操作就打在误认的那一条上。
|
||||||
|
it('编辑用户后刷新列表要沿用当前的搜索关键字', async () => {
|
||||||
|
const store = useUsersStore()
|
||||||
|
|
||||||
|
await store.fetchUsers(2, 'zhangsan')
|
||||||
|
usersApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.updateUser(7, { username: 'zhangsan', role_ids: [2] })
|
||||||
|
|
||||||
|
expect(usersApi.getAll).toHaveBeenCalledWith({
|
||||||
|
limit: 20,
|
||||||
|
offset: 20,
|
||||||
|
search: 'zhangsan'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删除用户后刷新列表要沿用当前的搜索关键字', async () => {
|
||||||
|
const store = useUsersStore()
|
||||||
|
|
||||||
|
await store.fetchUsers(1, 'zhangsan')
|
||||||
|
usersApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.deleteUser(7)
|
||||||
|
|
||||||
|
expect(usersApi.getAll).toHaveBeenCalledWith({
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
search: 'zhangsan'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 这处刻意与上面相反:新建的用户不一定匹配当前关键字,沿用搜索就看不见它,用户会
|
||||||
|
// 以为没建成功。
|
||||||
|
it('新建用户后要清空筛选并回到第 1 页', async () => {
|
||||||
|
const store = useUsersStore()
|
||||||
|
|
||||||
|
await store.fetchUsers(3, 'zhangsan')
|
||||||
|
usersApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.createUser({ username: 'lisi', role_ids: [2] })
|
||||||
|
|
||||||
|
expect(usersApi.getAll).toHaveBeenCalledWith({
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
search: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。
|
||||||
|
it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => {
|
||||||
|
const store = useUsersStore()
|
||||||
|
|
||||||
|
await store.fetchUsers(2, 'zhangsan')
|
||||||
|
usersApi.getAll.mockClear()
|
||||||
|
|
||||||
|
await store.fetchUsers()
|
||||||
|
|
||||||
|
expect(usersApi.getAll).toHaveBeenCalledWith({
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
search: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
108
frontend/src/views/Application.test.js
Normal file
108
frontend/src/views/Application.test.js
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import ElementPlus, { ElMessage } from 'element-plus'
|
||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||||
|
import Application from './Application.vue'
|
||||||
|
|
||||||
|
const applicationStore = vi.hoisted(() => ({
|
||||||
|
myApplications: [],
|
||||||
|
loading: false,
|
||||||
|
myPage: { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 },
|
||||||
|
fetchMyApplications: vi.fn(),
|
||||||
|
createApplication: vi.fn(),
|
||||||
|
cancelApplication: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const materialsStore = vi.hoisted(() => ({
|
||||||
|
materialOptions: [],
|
||||||
|
fetchMaterialOptions: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const stockStore = vi.hoisted(() => ({
|
||||||
|
fetchStock: vi.fn(),
|
||||||
|
fetchStockByMaterialId: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../stores/application', () => ({ useApplicationStore: () => applicationStore }))
|
||||||
|
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
|
||||||
|
vi.mock('../stores/stock', () => ({ useStockStore: () => stockStore }))
|
||||||
|
|
||||||
|
// 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报
|
||||||
|
// "Maximum recursive updates exceeded"。这里用替身直接派发 change,
|
||||||
|
// 模拟用户在状态下拉里选中某一项。
|
||||||
|
const SelectStub = {
|
||||||
|
props: ['modelValue', 'placeholder', 'filterable', 'filterMethod', 'noDataText', 'disabled', 'clearable'],
|
||||||
|
emits: ['update:modelValue', 'change'],
|
||||||
|
methods: {
|
||||||
|
pick(value) {
|
||||||
|
this.$emit('update:modelValue', value)
|
||||||
|
this.$emit('change', value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: '<div class="select-stub"></div>'
|
||||||
|
}
|
||||||
|
|
||||||
|
const OptionStub = { props: ['label', 'value'], template: '<span />' }
|
||||||
|
|
||||||
|
function mountApplication() {
|
||||||
|
return mount(Application, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
stubs: { teleport: true, ElSelect: SelectStub, ElOption: OptionStub }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面里有两处 ElSelect:申请表单里的物料选择,和「我的申请」里的状态筛选。
|
||||||
|
function statusSelect(wrapper) {
|
||||||
|
return wrapper.findAllComponents(SelectStub)
|
||||||
|
.find(select => select.props('placeholder') === '全部状态')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupState(wrapper) {
|
||||||
|
return wrapper.vm.$.setupState
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('提交申请后筛选条件与列表保持一致', () => {
|
||||||
|
let successSpy
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
applicationStore.createApplication.mockResolvedValue({ data: {} })
|
||||||
|
successSpy = vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('状态下拉选中的值会传给列表查询', async () => {
|
||||||
|
const wrapper = mountApplication()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
statusSelect(wrapper).vm.pick('approved')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(setupState(wrapper).statusFilter).toBe('approved')
|
||||||
|
expect(applicationStore.fetchMyApplications).toHaveBeenLastCalledWith('approved', 1)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// store 里提交后的那次刷新是刻意清空筛选的(好让刚提交的待审批单出现在列表里),
|
||||||
|
// 页面这份状态(下拉框)必须跟着一起回到「全部」:否则会出现下拉写着「已通过」、
|
||||||
|
// 列表却是全量的表里不一,这次修复就是补的这一处。
|
||||||
|
test('提交成功后下拉框的筛选要一起重置为全部', async () => {
|
||||||
|
const wrapper = mountApplication()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
statusSelect(wrapper).vm.pick('approved')
|
||||||
|
await flushPromises()
|
||||||
|
expect(setupState(wrapper).statusFilter).toBe('approved')
|
||||||
|
|
||||||
|
setupState(wrapper).applyForm.purpose = '测试用途'
|
||||||
|
setupState(wrapper).applyForm.items = [
|
||||||
|
{ material_id: 7, materialSearchText: '', quantity: 1 }
|
||||||
|
]
|
||||||
|
await setupState(wrapper).handleSubmit()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(applicationStore.createApplication).toHaveBeenCalled()
|
||||||
|
expect(setupState(wrapper).statusFilter).toBe('')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -212,6 +212,9 @@ async function handleSubmit() {
|
||||||
applyForm.purpose = ''
|
applyForm.purpose = ''
|
||||||
applyForm.items = [{ material_id: null, materialSearchText: '', quantity: 1 }]
|
applyForm.items = [{ material_id: null, materialSearchText: '', quantity: 1 }]
|
||||||
activeTab.value = 'my'
|
activeTab.value = 'my'
|
||||||
|
// store 里提交后的那次刷新会清空筛选(好让刚提交的待审批单出现在列表里),
|
||||||
|
// 下拉框得跟着一起回到「全部」,否则会出现下拉写着「已通过」、列表却是全量。
|
||||||
|
statusFilter.value = ''
|
||||||
} finally {
|
} finally {
|
||||||
submitLoading.value = false
|
submitLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<el-option label="待处理" value="pending" />
|
<el-option label="待处理" value="pending" />
|
||||||
<el-option label="已通过" value="approved" />
|
<el-option label="已通过" value="approved" />
|
||||||
<el-option label="已拒绝" value="rejected" />
|
<el-option label="已拒绝" value="rejected" />
|
||||||
|
<el-option label="已取消" value="cancelled" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
|
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||||
import Materials from './Materials.vue'
|
import Materials from './Materials.vue'
|
||||||
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData'
|
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData'
|
||||||
|
|
||||||
const materialsStore = vi.hoisted(() => ({
|
const materialsStore = vi.hoisted(() => ({
|
||||||
materials: [],
|
materials: [],
|
||||||
materialOptions: [],
|
materialOptions: [],
|
||||||
|
historicalMaterialOptions: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
pagination: { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 },
|
pagination: { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 },
|
||||||
fetchMaterials: vi.fn(),
|
fetchMaterials: vi.fn(),
|
||||||
fetchMaterialOptions: vi.fn(),
|
fetchMaterialOptions: vi.fn(),
|
||||||
|
fetchHistoricalMaterialOptions: vi.fn(),
|
||||||
createMaterial: vi.fn(),
|
createMaterial: vi.fn(),
|
||||||
updateMaterial: vi.fn(),
|
updateMaterial: vi.fn(),
|
||||||
obsoleteMaterial: vi.fn(),
|
obsoleteMaterial: vi.fn(),
|
||||||
|
|
@ -71,6 +73,10 @@ function codeInput(wrapper) {
|
||||||
return wrapper.find('.material-code-field input')
|
return wrapper.find('.material-code-field input')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupState(wrapper) {
|
||||||
|
return wrapper.vm.$.setupState
|
||||||
|
}
|
||||||
|
|
||||||
async function openAddDialog(wrapper) {
|
async function openAddDialog(wrapper) {
|
||||||
await findButton(wrapper, '新增物料').trigger('click')
|
await findButton(wrapper, '新增物料').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
@ -231,3 +237,146 @@ describe('新增物料时的编码联动', () => {
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('新增物料后列表与搜索框保持一致', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
materialCategoriesApi.list.mockResolvedValue({ data: categories })
|
||||||
|
unitsApi.list.mockResolvedValue({ data: [] })
|
||||||
|
codeRulesApi.next.mockReset()
|
||||||
|
codeRulesApi.next.mockResolvedValue({ data: { available: true, code: 'A010201' } })
|
||||||
|
materialsStore.createMaterial.mockReset()
|
||||||
|
materialsStore.updateMaterial.mockReset()
|
||||||
|
materialsStore.createMaterial.mockResolvedValue({ data: {} })
|
||||||
|
materialsStore.updateMaterial.mockResolvedValue({ data: {} })
|
||||||
|
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 页面里第一个 ElSelect 就是物料搜索框。
|
||||||
|
async function searchBy(wrapper, keyword) {
|
||||||
|
await wrapper.findAllComponents(SelectStub)[0].vm.pick(keyword)
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
// store 里新建后的那次刷新是刻意清空筛选的(好让刚建的物料出现在列表里),页面这份
|
||||||
|
// 状态(搜索框)必须跟着一起清空:否则会出现搜索框写着关键字、列表却是全量的
|
||||||
|
// 表里不一,用户会把全量列表当成筛选结果。
|
||||||
|
test('新增物料成功后搜索框要清空', async () => {
|
||||||
|
const wrapper = mountMaterials()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await searchBy(wrapper, '螺丝')
|
||||||
|
expect(setupState(wrapper).searchKeyword).toBe('螺丝')
|
||||||
|
|
||||||
|
await openAddDialog(wrapper)
|
||||||
|
await pickCategories(wrapper)
|
||||||
|
setupState(wrapper).form.name = '内六角螺丝'
|
||||||
|
await setupState(wrapper).handleSave()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(materialsStore.createMaterial).toHaveBeenCalled()
|
||||||
|
expect(setupState(wrapper).searchKeyword).toBe('')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 编辑走的是「沿用搜索」那条路:刷新后列表仍是筛选结果,搜索框不该被动。
|
||||||
|
test('编辑物料成功后搜索框要保留', async () => {
|
||||||
|
const wrapper = mountMaterials()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await searchBy(wrapper, '螺丝')
|
||||||
|
|
||||||
|
setupState(wrapper).showEditModal({
|
||||||
|
id: 7, category_id: 12, material_code: 'A010201', name: '螺丝'
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await setupState(wrapper).handleSave()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(materialsStore.updateMaterial).toHaveBeenCalled()
|
||||||
|
expect(setupState(wrapper).searchKeyword).toBe('螺丝')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('物料检索框的候选项', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
materialCategoriesApi.list.mockResolvedValue({ data: categories })
|
||||||
|
unitsApi.list.mockResolvedValue({ data: [] })
|
||||||
|
materialsStore.fetchHistoricalMaterialOptions.mockReset()
|
||||||
|
materialsStore.materialOptions = []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 这一页的列表查询走 status='all',本身就显示着作废物料;检索框的候选要是只取有效
|
||||||
|
// 物料,作废的那些就搜不出来——而这一页正是恢复它们的入口。列表和检索框必须同源。
|
||||||
|
test('能检索到已作废的物料', async () => {
|
||||||
|
const obsolete = { id: 1, material_code: 'A010201', name: '作废物料', status: 'obsolete' }
|
||||||
|
materialsStore.historicalMaterialOptions = [obsolete]
|
||||||
|
|
||||||
|
const wrapper = mountMaterials()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(materialsStore.fetchHistoricalMaterialOptions).toHaveBeenCalled()
|
||||||
|
|
||||||
|
setupState(wrapper).materialFilter('A010201')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(setupState(wrapper).getFilteredMaterials()).toEqual([obsolete])
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('物料列表的作废入口文案', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
materialCategoriesApi.list.mockResolvedValue({ data: categories })
|
||||||
|
unitsApi.list.mockResolvedValue({ data: [] })
|
||||||
|
// 名字特意避开按钮文案(「作废物料」/「撤销作废」):否则按钮自身就在那一行里,
|
||||||
|
// 用 toContain 断言行归属会恒为真,测了等于没测。
|
||||||
|
materialsStore.materials = [
|
||||||
|
{ id: 1, material_code: 'A010201', name: '旧螺丝', status: 'obsolete' },
|
||||||
|
{ id: 2, material_code: 'A010202', name: '新螺丝', status: 'active' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
materialsStore.materials = []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按钮文案得精确匹配:「作废物料」和「撤销作废」都含「作废」,用 includes 会把
|
||||||
|
// 两个按钮看成同一个。
|
||||||
|
function exactButton(wrapper, text) {
|
||||||
|
return wrapper.findAll('button').find(button => button.text().trim() === text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这一页没有状态列(文案即状态),所以按钮文案本身得说清这条物料怎么了。
|
||||||
|
// 「撤销」在中文 UI 里默认指撤销上一步(Ctrl+Z),看不出在撤销什么;「撤销作废」
|
||||||
|
// 才读得出「这条被作废了,点这里撤销那次作废」。
|
||||||
|
test('作废的那行显示「撤销作废」,有效的那行显示「作废物料」', async () => {
|
||||||
|
const wrapper = mountMaterials()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const restore = exactButton(wrapper, '撤销作废')
|
||||||
|
const obsolete = exactButton(wrapper, '作废物料')
|
||||||
|
|
||||||
|
expect(restore).toBeTruthy()
|
||||||
|
expect(obsolete).toBeTruthy()
|
||||||
|
// 两个入口互斥:作废的那行不该再有「作废物料」,有效的那行不该有「撤销作废」。
|
||||||
|
// 注意断言的是行归属,不是「页面上有没有这个词」——两个词都含「作废」。
|
||||||
|
expect(restore.element.closest('tr').textContent).toContain('旧螺丝')
|
||||||
|
expect(obsolete.element.closest('tr').textContent).toContain('新螺丝')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 两个按钮轮流出现在同一列,宽度不一样会让「编辑」右边参差不齐。这里靠的是两边
|
||||||
|
// 都是 4 个中文字,同字体同字号下自然等宽——jsdom 不做布局计算,量不了像素,但
|
||||||
|
// 字数不等就一定会参差,所以把字数锁住。
|
||||||
|
test('两个按钮的文案字数相同,保证宽度一致', async () => {
|
||||||
|
const wrapper = mountMaterials()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const restore = exactButton(wrapper, '撤销作废')
|
||||||
|
const obsolete = exactButton(wrapper, '作废物料')
|
||||||
|
|
||||||
|
expect(restore.text().trim().length).toBe(obsolete.text().trim().length)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,8 @@
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="showDetail(row)">详情</el-button>
|
<el-button size="small" @click="showDetail(row)">详情</el-button>
|
||||||
<el-button v-if="authStore.isAdmin" size="small" type="primary" @click="showEditModal(row)">编辑</el-button>
|
<el-button v-if="authStore.isAdmin" size="small" type="primary" @click="showEditModal(row)">编辑</el-button>
|
||||||
<el-button v-if="authStore.isAdmin && row.status !== 'obsolete'" size="small" type="danger" @click="handleDelete(row.id)">作废</el-button>
|
<el-button v-if="authStore.isAdmin && row.status !== 'obsolete'" size="small" type="danger" @click="handleDelete(row.id)">作废物料</el-button>
|
||||||
<el-button v-if="authStore.isAdmin && row.status === 'obsolete'" size="small" type="success" @click="handleRestore(row.id)">撤销</el-button>
|
<el-button v-if="authStore.isAdmin && row.status === 'obsolete'" size="small" type="success" @click="handleRestore(row.id)">撤销作废</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</BaseTable>
|
</BaseTable>
|
||||||
|
|
@ -217,7 +217,10 @@ const form = reactive(createMaterialForm())
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
materialsStore.fetchMaterials()
|
materialsStore.fetchMaterials()
|
||||||
materialsStore.fetchMaterialOptions()
|
// 用含作废的那份选项:这一页的列表查询走 status='all',本身就显示着作废物料,
|
||||||
|
// 检索框却只给有效物料的话,作废的那些就搜不出来(而这一页正是恢复它们的入口)。
|
||||||
|
// 业务单据页(入库/出库/申请)仍用只有有效物料的 materialOptions。
|
||||||
|
materialsStore.fetchHistoricalMaterialOptions()
|
||||||
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')
|
||||||
|
|
@ -302,7 +305,8 @@ function materialFilter(val) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilteredMaterials() {
|
function getFilteredMaterials() {
|
||||||
return searchMaterialOptions(materialsStore.materialOptions, materialSearchText.value)
|
// 同 onMounted 的说明:候选要含作废物料,才对得上面向全量的列表查询。
|
||||||
|
return searchMaterialOptions(materialsStore.historicalMaterialOptions, materialSearchText.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page) {
|
function handlePageChange(page) {
|
||||||
|
|
@ -375,6 +379,9 @@ async function handleSave() {
|
||||||
} else {
|
} else {
|
||||||
await materialsStore.createMaterial(formData)
|
await materialsStore.createMaterial(formData)
|
||||||
ElMessage.success('物料创建成功')
|
ElMessage.success('物料创建成功')
|
||||||
|
// store 里新建后的那次刷新会清空筛选(好让刚建的物料出现在列表里),搜索框得
|
||||||
|
// 跟着一起清空,否则会出现搜索框写着关键字、列表却是全量的表里不一。
|
||||||
|
searchKeyword.value = ''
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus, { ElMessage } from 'element-plus'
|
||||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||||
import Users from './Users.vue'
|
import Users from './Users.vue'
|
||||||
import { organizationsApi } from '../api/organizations'
|
import { organizationsApi } from '../api/organizations'
|
||||||
|
|
@ -36,6 +36,15 @@ vi.mock('../api/organizations', () => ({
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
function mountUsers() {
|
||||||
|
return mount(Users, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
stubs: { teleport: true, ElSelect: RoleSelectStub, ElOption: RoleOptionStub }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('用户管理角色', () => {
|
describe('用户管理角色', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
usersStore.users = [{
|
usersStore.users = [{
|
||||||
|
|
@ -91,3 +100,58 @@ describe('用户管理角色', () => {
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('新增用户后列表与搜索框保持一致', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
usersStore.fetchUsers.mockReset()
|
||||||
|
usersStore.createUser.mockReset()
|
||||||
|
usersStore.updateUser.mockReset()
|
||||||
|
usersStore.createUser.mockResolvedValue({ data: {} })
|
||||||
|
usersStore.updateUser.mockResolvedValue({ data: {} })
|
||||||
|
organizationsApi.listRoles.mockResolvedValue({ data: [] })
|
||||||
|
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
// store 里新建后的那次刷新是刻意清空筛选的(好让刚建的用户出现在列表里),页面这份
|
||||||
|
// 状态(搜索框)必须跟着一起清空:否则会出现搜索框写着用户名、列表却是全量的
|
||||||
|
// 表里不一,用户会把全量列表当成筛选结果。
|
||||||
|
test('新增用户成功后搜索框要清空', async () => {
|
||||||
|
const wrapper = mountUsers()
|
||||||
|
await flushPromises()
|
||||||
|
const state = wrapper.vm.$.setupState
|
||||||
|
|
||||||
|
await wrapper.find('.search-bar input').setValue('zhangsan')
|
||||||
|
await flushPromises()
|
||||||
|
expect(state.searchKeyword).toBe('zhangsan')
|
||||||
|
|
||||||
|
state.form.username = 'lisi'
|
||||||
|
state.form.password = '123456'
|
||||||
|
state.form.role_ids = [2]
|
||||||
|
await state.handleSave()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(usersStore.createUser).toHaveBeenCalled()
|
||||||
|
expect(state.searchKeyword).toBe('')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 编辑走的是「沿用搜索」那条路:刷新后列表仍是筛选结果,搜索框不该被动。
|
||||||
|
test('编辑用户成功后搜索框要保留', async () => {
|
||||||
|
const wrapper = mountUsers()
|
||||||
|
await flushPromises()
|
||||||
|
const state = wrapper.vm.$.setupState
|
||||||
|
|
||||||
|
await wrapper.find('.search-bar input').setValue('zhangsan')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
state.editingId = 7
|
||||||
|
state.form.username = 'zhangsan'
|
||||||
|
state.form.role_ids = [2]
|
||||||
|
await state.handleSave()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(usersStore.updateUser).toHaveBeenCalled()
|
||||||
|
expect(state.searchKeyword).toBe('zhangsan')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,9 @@ async function handleSave() {
|
||||||
await usersStore.updateUser(editingId.value, data)
|
await usersStore.updateUser(editingId.value, data)
|
||||||
} else {
|
} else {
|
||||||
await usersStore.createUser(data)
|
await usersStore.createUser(data)
|
||||||
|
// store 里新建后的那次刷新会清空筛选(好让刚建的用户出现在列表里),搜索框得
|
||||||
|
// 跟着一起清空,否则会出现搜索框写着关键字、列表却是全量的表里不一。
|
||||||
|
searchKeyword.value = ''
|
||||||
}
|
}
|
||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user