优化
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
changePlatformLocationStatus,
|
||||
createPlatformLocation,
|
||||
deletePlatformLocation,
|
||||
getPlatformLocation,
|
||||
listPlatformLocations,
|
||||
updatePlatformLocation,
|
||||
type SavePlatformLocationRequest,
|
||||
} from './platformLocations'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const payload: SavePlatformLocationRequest = {
|
||||
warehouseId: '190000000000000101',
|
||||
zoneCode: 'A',
|
||||
zoneName: '常规区',
|
||||
locationCode: 'A1-01',
|
||||
locationName: 'A1 区 01 位',
|
||||
locationType: 'SHELF',
|
||||
maxWeightKg: 500,
|
||||
status: 'AVAILABLE',
|
||||
remark: null,
|
||||
version: 3,
|
||||
}
|
||||
|
||||
describe('platform location API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
vi.mocked(http.patch).mockReset()
|
||||
vi.mocked(http.delete).mockReset()
|
||||
})
|
||||
|
||||
it('uses the implemented paged list and detail contracts', async () => {
|
||||
const page = { records: [], page: '2', size: '20', total: '21', pages: '2' }
|
||||
const detail = { location: { id: '19001' }, warehouseCode: 'CN-YW-01', warehouseName: '义乌仓' }
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: page })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: detail })
|
||||
|
||||
await expect(listPlatformLocations({
|
||||
page: 2,
|
||||
size: 20,
|
||||
tenantId: '9223372036854775806',
|
||||
warehouseId: '190000000000000101',
|
||||
zoneCode: ' A ',
|
||||
locationCode: ' A1 ',
|
||||
locationType: 'SHELF',
|
||||
status: 'AVAILABLE',
|
||||
})).resolves.toEqual({ records: [], page: 2, size: 20, total: 21, pages: 2 })
|
||||
await expect(getPlatformLocation('9223372036854775806', '190000000000000101', '19001')).resolves.toEqual(detail)
|
||||
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/locations', {
|
||||
params: {
|
||||
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '190000000000000101',
|
||||
zoneCode: 'A', locationCode: 'A1', locationType: 'SHELF', status: 'AVAILABLE',
|
||||
},
|
||||
})
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/locations/19001', {
|
||||
params: { tenantId: '9223372036854775806', warehouseId: '190000000000000101' },
|
||||
})
|
||||
})
|
||||
|
||||
it('omits empty customer, warehouse, and filter values from all-customer list requests', async () => {
|
||||
const page = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: page })
|
||||
|
||||
await expect(listPlatformLocations({
|
||||
page: 1,
|
||||
size: 20,
|
||||
tenantId: '',
|
||||
warehouseId: '',
|
||||
zoneCode: '',
|
||||
locationCode: '',
|
||||
locationType: '',
|
||||
status: '',
|
||||
})).resolves.toEqual(page)
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/locations', {
|
||||
params: { page: 1, size: 20 },
|
||||
})
|
||||
})
|
||||
|
||||
it('sends versioned create, update, status, and delete requests', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: '19001' })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
|
||||
await expect(createPlatformLocation(10001, payload)).resolves.toBe('19001')
|
||||
await expect(updatePlatformLocation(10001, payload.warehouseId, '19001', payload)).resolves.toBeNull()
|
||||
await expect(changePlatformLocationStatus(10001, payload.warehouseId, '19001', 'DISABLED', 3)).resolves.toBeNull()
|
||||
await expect(deletePlatformLocation(10001, payload.warehouseId, '19001', 4)).resolves.toBeNull()
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/locations', payload, { params: { tenantId: 10001 } })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/locations/19001', payload, {
|
||||
params: { tenantId: 10001, warehouseId: payload.warehouseId },
|
||||
})
|
||||
expect(http.patch).toHaveBeenCalledWith('/platform/v1/locations/19001/status', { status: 'DISABLED', version: 3 }, {
|
||||
params: { tenantId: 10001, warehouseId: payload.warehouseId },
|
||||
})
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/locations/19001', {
|
||||
params: { tenantId: 10001, warehouseId: payload.warehouseId, version: 4 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type PlatformLocationStatus = 'AVAILABLE' | 'RESERVED' | 'OCCUPIED' | 'DISABLED'
|
||||
export type EditablePlatformLocationStatus = 'AVAILABLE' | 'DISABLED'
|
||||
export type PlatformLocationType = 'SHELF' | 'PALLET' | 'FLOOR' | 'FLOW_RACK'
|
||||
|
||||
export interface PlatformLocationResource {
|
||||
id: number | string
|
||||
tenantId: number | string
|
||||
warehouseId: number | string
|
||||
warehouseCode?: string | null
|
||||
warehouseName?: string | null
|
||||
zoneCode: string
|
||||
zoneName: string
|
||||
locationCode: string
|
||||
locationName: string | null
|
||||
locationType: PlatformLocationType
|
||||
maxWeightKg: number | string | null
|
||||
sharedOverflow: boolean | null
|
||||
status: PlatformLocationStatus
|
||||
remark: string | null
|
||||
version: number
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface PlatformLocationDetail {
|
||||
location: PlatformLocationResource
|
||||
warehouseCode: string
|
||||
warehouseName: string
|
||||
}
|
||||
|
||||
export interface PlatformLocationPage {
|
||||
records: PlatformLocationResource[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface PlatformLocationQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
tenantId?: number | string
|
||||
warehouseId?: number | string
|
||||
zoneCode?: string
|
||||
locationCode?: string
|
||||
locationType?: PlatformLocationType | ''
|
||||
status?: PlatformLocationStatus | ''
|
||||
}
|
||||
|
||||
export interface SavePlatformLocationRequest {
|
||||
warehouseId: number | string
|
||||
zoneCode: string
|
||||
zoneName: string
|
||||
locationCode: string
|
||||
locationName: string | null
|
||||
locationType: PlatformLocationType
|
||||
maxWeightKg: number | null
|
||||
status: EditablePlatformLocationStatus
|
||||
remark: string | null
|
||||
version?: number
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/locations'
|
||||
|
||||
export async function listPlatformLocations(query: PlatformLocationQuery) {
|
||||
const zoneCode = query.zoneCode?.trim()
|
||||
const locationCode = query.locationCode?.trim()
|
||||
const response = await http.get<ApiEnvelope<PlatformLocationPage>, ApiEnvelope<PlatformLocationPage>>(basePath, {
|
||||
params: {
|
||||
page: query.page ?? 1,
|
||||
size: query.size ?? 20,
|
||||
...(query.tenantId ? { tenantId: query.tenantId } : {}),
|
||||
...(query.warehouseId ? { warehouseId: query.warehouseId } : {}),
|
||||
...(zoneCode ? { zoneCode } : {}),
|
||||
...(locationCode ? { locationCode } : {}),
|
||||
...(query.locationType ? { locationType: query.locationType } : {}),
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
},
|
||||
})
|
||||
const result = unwrapApiResponse(response)
|
||||
return {
|
||||
...result,
|
||||
page: Number(result.page),
|
||||
size: Number(result.size),
|
||||
total: Number(result.total),
|
||||
pages: Number(result.pages),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformLocation(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
) {
|
||||
const response = await http.get<ApiEnvelope<PlatformLocationDetail>, ApiEnvelope<PlatformLocationDetail>>(
|
||||
`${basePath}/${id}`,
|
||||
{ params: { tenantId, warehouseId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function createPlatformLocation(tenantId: number | string, payload: SavePlatformLocationRequest) {
|
||||
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload, {
|
||||
params: { tenantId },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updatePlatformLocation(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
payload: SavePlatformLocationRequest,
|
||||
) {
|
||||
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, payload, {
|
||||
params: { tenantId, warehouseId },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function changePlatformLocationStatus(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
status: EditablePlatformLocationStatus,
|
||||
version: number,
|
||||
) {
|
||||
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(
|
||||
`${basePath}/${id}/status`,
|
||||
{ status, version },
|
||||
{ params: { tenantId, warehouseId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function deletePlatformLocation(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
version: number,
|
||||
) {
|
||||
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, {
|
||||
params: { tenantId, warehouseId, version },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import {
|
||||
getPlatformLocation,
|
||||
listPlatformLocations,
|
||||
type PlatformLocationResource,
|
||||
} from '../api/platformLocations'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '../../../iam/api/platformIam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import LocationsView from './LocationsView.vue'
|
||||
|
||||
vi.mock('../../../iam/api/platformIam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../../../warehouses/api/platformWarehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformLocations', () => ({
|
||||
listPlatformLocations: vi.fn(),
|
||||
getPlatformLocation: vi.fn(),
|
||||
createPlatformLocation: vi.fn(),
|
||||
updatePlatformLocation: vi.fn(),
|
||||
changePlatformLocationStatus: vi.fn(),
|
||||
deletePlatformLocation: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
const tenant = (id: number | string): PlatformTenantResource => ({
|
||||
id,
|
||||
tenantCode: String(id),
|
||||
tenantName: '雪花企业',
|
||||
status: 'ACTIVE',
|
||||
legalName: '雪花企业',
|
||||
legalRepresentative: null,
|
||||
unifiedSocialCreditCode: null,
|
||||
contactName: null,
|
||||
contactPhone: null,
|
||||
contactEmail: null,
|
||||
servicePackage: null,
|
||||
contactWechat: null,
|
||||
salesOwnerName: null,
|
||||
paymentTermDays: null,
|
||||
enterpriseType: null,
|
||||
establishedDate: null,
|
||||
registeredCapital: null,
|
||||
registeredCapitalCurrency: null,
|
||||
registeredAddress: null,
|
||||
internalRemark: null,
|
||||
createdAt: null,
|
||||
approvedAt: null,
|
||||
})
|
||||
|
||||
const tenantId = '9223372036854775806'
|
||||
const warehouseId = '9223372036854775805'
|
||||
const warehouse: PlatformWarehouseResource = {
|
||||
id: warehouseId,
|
||||
tenantId,
|
||||
warehouseCode: 'CN-YW-01',
|
||||
warehouseName: '义乌收货仓',
|
||||
warehouseType: 'DOMESTIC',
|
||||
countryCode: 'CN',
|
||||
timezone: 'Asia/Shanghai',
|
||||
contactName: null,
|
||||
contactPhone: null,
|
||||
contactEmail: null,
|
||||
status: 'ACTIVE',
|
||||
remark: null,
|
||||
}
|
||||
|
||||
const availableLocation: PlatformLocationResource = {
|
||||
id: '9223372036854775804',
|
||||
tenantId,
|
||||
warehouseId,
|
||||
zoneCode: 'A',
|
||||
zoneName: '常规区',
|
||||
locationCode: 'A1-01',
|
||||
locationName: 'A1 区 01 位',
|
||||
locationType: 'SHELF',
|
||||
maxWeightKg: '500.000',
|
||||
sharedOverflow: false,
|
||||
status: 'AVAILABLE',
|
||||
remark: '靠近入库口',
|
||||
version: 3,
|
||||
updatedAt: '2026-08-04T10:20:00',
|
||||
}
|
||||
|
||||
const occupiedLocation: PlatformLocationResource = {
|
||||
...availableLocation,
|
||||
id: '9223372036854775803',
|
||||
locationCode: 'A1-02',
|
||||
locationName: 'A1 区 02 位',
|
||||
status: 'OCCUPIED',
|
||||
version: 5,
|
||||
}
|
||||
|
||||
async function mountView() {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = [
|
||||
'platform:location:list',
|
||||
'platform:location:create',
|
||||
'platform:location:edit',
|
||||
'platform:location:status',
|
||||
'platform:location:delete',
|
||||
]
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(LocationsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function selectScope(wrapper: VueWrapper) {
|
||||
let toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), customerId: tenantId })
|
||||
await flushPromises()
|
||||
toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), warehouseId })
|
||||
await flushPromises()
|
||||
const searchButton = toolbar.findAll('button').find((button) => button.text().trim() === '查询')
|
||||
await searchButton!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('LocationsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({
|
||||
records: [tenant(tenantId)], page: 1, size: 200, total: 1, pages: 1,
|
||||
})
|
||||
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({
|
||||
records: [warehouse], page: 1, size: 200, total: 1, pages: 1,
|
||||
})
|
||||
vi.mocked(listPlatformLocations).mockReset().mockResolvedValue({
|
||||
records: [availableLocation, occupiedLocation], page: 1, size: 20, total: 2, pages: 1,
|
||||
})
|
||||
vi.mocked(getPlatformLocation).mockReset().mockResolvedValue({
|
||||
location: availableLocation, warehouseCode: warehouse.warehouseCode, warehouseName: warehouse.warehouseName,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts with all customers and queries the full list without empty IDs', async () => {
|
||||
const wrapper = await mountView()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const fields = toolbar.props('fields') as QueryField[]
|
||||
|
||||
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||
expect(wrapper.find('[aria-label="所属企业"]').exists()).toBe(true)
|
||||
expect(fields[0]?.placeholder).toBe('全部客户')
|
||||
expect((toolbar.props('modelValue') as QueryModel).customerId).toBe('')
|
||||
expect(fields.map(({ key, width }) => [key, width])).toEqual([
|
||||
['customerId', 260], ['warehouseId', 240], ['zoneCode', 210],
|
||||
['locationCode', 220], ['locationType', 170], ['status', 160],
|
||||
])
|
||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
||||
expect(listPlatformLocations).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
zoneCode: '',
|
||||
locationCode: '',
|
||||
locationType: '',
|
||||
status: '',
|
||||
})
|
||||
const searchButton = toolbar.findAll('button').find((button) => button.text().trim() === '查询')
|
||||
await searchButton!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listPlatformLocations).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).not.toContain('请选择客户')
|
||||
expect(wrapper.text()).not.toContain('请选择所属企业')
|
||||
expect(wrapper.text()).not.toContain('租户')
|
||||
expect(wrapper.text()).not.toContain('企业 ID')
|
||||
})
|
||||
|
||||
it('loads the selected customer, warehouse, and location hierarchy without narrowing snowflake IDs', async () => {
|
||||
const wrapper = await mountView()
|
||||
await selectScope(wrapper)
|
||||
|
||||
expect(wrapper.find('.table-panel .panel-heading').exists()).toBe(false)
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(tenantId, 1, 200)
|
||||
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
tenantId,
|
||||
warehouseId,
|
||||
zoneCode: '',
|
||||
locationCode: '',
|
||||
locationType: '',
|
||||
status: '',
|
||||
})
|
||||
expect(wrapper.text()).toContain('义乌收货仓')
|
||||
expect(wrapper.text()).toContain('A1-01')
|
||||
expect(wrapper.findAll('thead th').map((item) => item.text().trim())).toEqual([
|
||||
'操作', '库位编码', '所属仓库', '库区', '库位类型', '最大承重(kg)', '状态', '备注',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the operation column first and hides edit actions for occupied resources', async () => {
|
||||
const wrapper = await mountView()
|
||||
await selectScope(wrapper)
|
||||
const headings = wrapper.findAll('thead th').map((item) => item.text().trim())
|
||||
expect(headings[0]).toBe('操作')
|
||||
|
||||
const bodyRows = wrapper.findAll('tbody tr')
|
||||
expect(bodyRows[0]?.text()).toContain('编辑')
|
||||
expect(bodyRows[1]?.text()).toContain('详情')
|
||||
expect(bodyRows[1]?.text()).not.toContain('编辑')
|
||||
expect(bodyRows[1]?.text()).not.toContain('更多')
|
||||
})
|
||||
|
||||
it('opens an authenticated detail view with warehouse context', async () => {
|
||||
const wrapper = await mountView()
|
||||
const detailButton = wrapper.find('.el-table__body-wrapper tbody tr button')
|
||||
expect(detailButton.text()).toBe('详情')
|
||||
await detailButton.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getPlatformLocation).toHaveBeenCalledWith(tenantId, warehouseId, availableLocation.id)
|
||||
expect(document.body.textContent).toContain('库位详情')
|
||||
expect(document.body.textContent).toContain('义乌收货仓(CN-YW-01)')
|
||||
expect(document.body.textContent).toContain(`所属企业雪花企业(${tenantId})`)
|
||||
expect(document.body.textContent).not.toContain('企业 ID')
|
||||
expect(document.body.textContent).toContain('库位名称A1 区 01 位')
|
||||
expect(document.body.textContent).toContain('v3')
|
||||
})
|
||||
|
||||
it('presents occupied locations as read-only business resources', async () => {
|
||||
vi.mocked(getPlatformLocation).mockResolvedValueOnce({
|
||||
location: occupiedLocation,
|
||||
warehouseCode: warehouse.warehouseCode,
|
||||
warehouseName: warehouse.warehouseName,
|
||||
})
|
||||
const wrapper = await mountView()
|
||||
const occupiedRow = wrapper.findAll('tbody tr')[1]
|
||||
await occupiedRow!.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('状态已占用')
|
||||
expect(document.body.textContent).toContain('当前库位正在被业务使用,只允许查看。')
|
||||
})
|
||||
|
||||
it('clears customer and warehouse on reset and immediately restores the full query', async () => {
|
||||
const wrapper = await mountView()
|
||||
await selectScope(wrapper)
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const resetButton = toolbar.findAll('button').find((button) => button.text().trim() === '重置')
|
||||
|
||||
await resetButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const model = wrapper.findComponent(QueryToolbar).props('modelValue') as QueryModel
|
||||
expect(model.customerId).toBe('')
|
||||
expect(model.warehouseId).toBe('')
|
||||
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
zoneCode: '',
|
||||
locationCode: '',
|
||||
locationType: '',
|
||||
status: '',
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('请选择客户')
|
||||
})
|
||||
|
||||
it('paginates the all-customer list without adding customer or warehouse IDs', async () => {
|
||||
vi.mocked(listPlatformLocations).mockResolvedValue({
|
||||
records: [availableLocation, occupiedLocation], page: 1, size: 20, total: 40, pages: 2,
|
||||
})
|
||||
const wrapper = await mountView()
|
||||
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
||||
page: 2,
|
||||
size: 20,
|
||||
zoneCode: '',
|
||||
locationCode: '',
|
||||
locationType: '',
|
||||
status: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,808 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, MapPin, MoreHorizontal, Pencil, Plus, Power, Trash2 } from '@lucide/vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import {
|
||||
changePlatformLocationStatus,
|
||||
createPlatformLocation,
|
||||
deletePlatformLocation,
|
||||
getPlatformLocation,
|
||||
listPlatformLocations,
|
||||
updatePlatformLocation,
|
||||
type EditablePlatformLocationStatus,
|
||||
type PlatformLocationDetail,
|
||||
type PlatformLocationQuery,
|
||||
type PlatformLocationResource,
|
||||
type PlatformLocationStatus,
|
||||
type PlatformLocationType,
|
||||
type SavePlatformLocationRequest,
|
||||
} from '../api/platformLocations'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
|
||||
|
||||
type DrawerMode = 'view' | 'create' | 'edit'
|
||||
type LocationFilters = QueryModel & {
|
||||
customerId: string
|
||||
warehouseId: string
|
||||
zoneCode: string
|
||||
locationCode: string
|
||||
locationType: PlatformLocationType | ''
|
||||
status: PlatformLocationStatus | ''
|
||||
}
|
||||
type AppliedLocationFilters = {
|
||||
zoneCode: string
|
||||
locationCode: string
|
||||
locationType: PlatformLocationType | ''
|
||||
status: PlatformLocationStatus | ''
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customerLoading = ref(false)
|
||||
const customerError = ref<ApiErrorDetails | null>(null)
|
||||
const loadedCustomerId = ref<string | null>(null)
|
||||
const warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const warehouseLoading = ref(false)
|
||||
const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const rows = ref<PlatformLocationResource[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const drawerOpen = ref(false)
|
||||
const drawerMode = ref<DrawerMode>('view')
|
||||
const drawerLoading = ref(false)
|
||||
const drawerError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<PlatformLocationDetail | null>(null)
|
||||
const saving = ref(false)
|
||||
const actionLoadingIds = ref(new Set<string>())
|
||||
const formRef = ref<FormInstance>()
|
||||
let customerLoadSequence = 0
|
||||
let locationLoadSequence = 0
|
||||
|
||||
const filters = ref<LocationFilters>({ customerId: '', warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
|
||||
const appliedFilters = ref<AppliedLocationFilters>({ zoneCode: '', locationCode: '', locationType: '', status: '' })
|
||||
const selectedCustomerId = computed(() => String(filters.value.customerId || ''))
|
||||
const selectedWarehouseId = computed(() => String(filters.value.warehouseId || ''))
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'customerId',
|
||||
type: 'select',
|
||||
width: 260,
|
||||
placeholder: customerError.value ? '客户列表加载失败' : '全部客户',
|
||||
props: { 'aria-label': '所属企业', filterable: true, disabled: customerLoading.value || Boolean(customerError.value) || selectionDisabled.value },
|
||||
options: customers.value.map((customer) => ({
|
||||
value: String(customer.id),
|
||||
label: `${customer.tenantName}(${customer.tenantCode || customer.id})`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'warehouseId',
|
||||
type: 'select',
|
||||
width: 240,
|
||||
placeholder: warehouseLoading.value ? '仓库加载中' : '所属仓库',
|
||||
props: {
|
||||
'aria-label': '所属仓库',
|
||||
filterable: true,
|
||||
disabled: selectionDisabled.value || warehouseLoading.value || !customerReady.value || warehouses.value.length === 0,
|
||||
},
|
||||
options: warehouses.value.map((warehouse) => ({
|
||||
value: String(warehouse.id),
|
||||
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
|
||||
disabled: warehouse.status !== 'ACTIVE',
|
||||
})),
|
||||
},
|
||||
{ key: 'zoneCode', type: 'input', width: 210, placeholder: '库区编码', props: { maxlength: 50, 'aria-label': '库区编码' } },
|
||||
{ key: 'locationCode', type: 'input', width: 220, placeholder: '库位编码', props: { maxlength: 50, 'aria-label': '库位编码' } },
|
||||
{
|
||||
key: 'locationType', type: 'select', width: 170, placeholder: '全部类型', props: { 'aria-label': '库位类型' },
|
||||
options: [
|
||||
{ label: '货架位', value: 'SHELF' },
|
||||
{ label: '托盘位', value: 'PALLET' },
|
||||
{ label: '地堆位', value: 'FLOOR' },
|
||||
{ label: '流利架', value: 'FLOW_RACK' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status', type: 'select', width: 160, placeholder: '全部状态', props: { 'aria-label': '库位状态' },
|
||||
options: [
|
||||
{ label: '可用', value: 'AVAILABLE' },
|
||||
{ label: '已预占', value: 'RESERVED' },
|
||||
{ label: '已占用', value: 'OCCUPIED' },
|
||||
{ label: '已停用', value: 'DISABLED' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const emptyForm = () => ({
|
||||
id: '',
|
||||
customerId: selectedCustomerId.value,
|
||||
warehouseId: selectedWarehouseId.value,
|
||||
zoneCode: '',
|
||||
zoneName: '',
|
||||
locationCode: '',
|
||||
locationName: '',
|
||||
locationType: 'SHELF' as PlatformLocationType,
|
||||
maxWeightKg: null as number | null,
|
||||
status: 'AVAILABLE' as EditablePlatformLocationStatus,
|
||||
remark: '',
|
||||
version: 0,
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
const rules: FormRules<typeof form> = {
|
||||
zoneCode: [{ required: true, message: '请输入库区编码', trigger: 'blur' }],
|
||||
zoneName: [{ required: true, message: '请输入库区名称', trigger: 'blur' }],
|
||||
locationCode: [
|
||||
{ required: true, message: '请输入库位编码', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (value.trim().toUpperCase() === 'A0') callback(new Error('A0 是系统共享溢出位保留编码'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
locationType: [{ required: true, message: '请选择库位类型', trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const selectedWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === selectedWarehouseId.value) ?? null)
|
||||
const customerReady = computed(() => Boolean(selectedCustomerId.value) && loadedCustomerId.value === selectedCustomerId.value && !warehouseLoading.value)
|
||||
const createReady = computed(() => customerReady.value && Boolean(selectedWarehouse.value) && !loading.value)
|
||||
const editorOpen = computed(() => drawerOpen.value && drawerMode.value !== 'view')
|
||||
const editorTitle = computed(() => drawerMode.value === 'create' ? '新增库位' : '编辑库位')
|
||||
const drawerTitle = computed(() => drawerMode.value === 'view' ? '库位详情' : editorTitle.value)
|
||||
const editorPermission = computed(() => drawerMode.value === 'create' ? 'platform:location:create' : 'platform:location:edit')
|
||||
const selectionDisabled = computed(() => drawerOpen.value || saving.value || actionLoadingIds.value.size > 0)
|
||||
const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim()))
|
||||
const emptyDescription = computed(() => filtered.value ? '暂无符合筛选条件的库位' : '暂无库位数据')
|
||||
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'location-editor',
|
||||
label: editorTitle,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: editorPermission,
|
||||
})
|
||||
|
||||
const statusMeta: Record<PlatformLocationStatus, { label: string; type: 'success' | 'warning' | 'danger' | 'info' }> = {
|
||||
AVAILABLE: { label: '可用', type: 'success' },
|
||||
RESERVED: { label: '已预占', type: 'warning' },
|
||||
OCCUPIED: { label: '已占用', type: 'danger' },
|
||||
DISABLED: { label: '已停用', type: 'info' },
|
||||
}
|
||||
const typeLabels: Record<PlatformLocationType, string> = {
|
||||
SHELF: '货架位',
|
||||
PALLET: '托盘位',
|
||||
FLOOR: '地堆位',
|
||||
FLOW_RACK: '流利架',
|
||||
}
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'location', label: '库位编码', minWidth: 190, slotName: 'location' },
|
||||
{ key: 'warehouse', label: '所属仓库', minWidth: 210, slotName: 'warehouse' },
|
||||
{ key: 'zone', label: '库区', minWidth: 150, slotName: 'zone' },
|
||||
{ key: 'locationType', label: '库位类型', width: 120, slotName: 'locationType' },
|
||||
{ key: 'maxWeightKg', label: '最大承重(kg)', minWidth: 140, slotName: 'maxWeightKg' },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||
{ key: 'remark', label: '备注', minWidth: 220, slotName: 'remark' },
|
||||
]
|
||||
|
||||
function apiId(value: string) {
|
||||
return value
|
||||
}
|
||||
|
||||
function optionalText(value: string) {
|
||||
return value.trim() || null
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
return value?.replace('T', ' ').slice(0, 16) || '—'
|
||||
}
|
||||
|
||||
function formatWeight(value: number | string | null) {
|
||||
if (value === null || value === '') return '—'
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? `${numeric.toLocaleString('zh-CN', { maximumFractionDigits: 3 })} kg` : `${value} kg`
|
||||
}
|
||||
|
||||
function canEdit(row: PlatformLocationResource) {
|
||||
return access.can('platform:location:edit')
|
||||
&& !row.sharedOverflow
|
||||
&& (row.status === 'AVAILABLE' || row.status === 'DISABLED')
|
||||
}
|
||||
|
||||
function canChangeStatus(row: PlatformLocationResource) {
|
||||
return access.can('platform:location:status')
|
||||
&& !row.sharedOverflow
|
||||
&& (row.status === 'AVAILABLE' || row.status === 'DISABLED')
|
||||
}
|
||||
|
||||
function canDelete(row: PlatformLocationResource) {
|
||||
return access.can('platform:location:delete') && !row.sharedOverflow && row.status === 'DISABLED'
|
||||
}
|
||||
|
||||
function actionError(error: unknown, fallback: string) {
|
||||
const details = getApiErrorDetails(error, fallback)
|
||||
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
|
||||
}
|
||||
|
||||
function requireCreateScope() {
|
||||
if (!createReady.value || !selectedCustomerId.value || !selectedWarehouseId.value) {
|
||||
ElMessage.warning('所属企业和仓库数据尚未加载完成,请稍后重试')
|
||||
return null
|
||||
}
|
||||
return { customerId: apiId(selectedCustomerId.value), warehouseId: apiId(selectedWarehouseId.value) }
|
||||
}
|
||||
|
||||
function recordScope(row: PlatformLocationResource) {
|
||||
return { customerId: apiId(String(row.tenantId)), warehouseId: apiId(String(row.warehouseId)) }
|
||||
}
|
||||
|
||||
function customerLabel(customerId: number | string) {
|
||||
const customer = customers.value.find((item) => String(item.id) === String(customerId))
|
||||
return customer ? `${customer.tenantName}(${customer.tenantCode || customer.id})` : String(customerId)
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
customerLoading.value = true
|
||||
customerError.value = null
|
||||
try {
|
||||
const result = await listPlatformTenants({ page: 1, size: 200 })
|
||||
customers.value = result.records
|
||||
} catch (error) {
|
||||
customers.value = []
|
||||
customerError.value = getApiErrorDetails(error, '所属企业列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
customerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWarehouses(customerId = selectedCustomerId.value) {
|
||||
if (!customerId) return
|
||||
const sequence = ++customerLoadSequence
|
||||
warehouseLoading.value = true
|
||||
warehouseError.value = null
|
||||
filters.value = { ...filters.value, warehouseId: '' }
|
||||
warehouses.value = []
|
||||
try {
|
||||
const result = await listPlatformWarehouses(apiId(customerId), 1, 200)
|
||||
if (sequence !== customerLoadSequence) return
|
||||
warehouses.value = result.records
|
||||
loadedCustomerId.value = customerId
|
||||
} catch (error) {
|
||||
if (sequence !== customerLoadSequence) return
|
||||
loadedCustomerId.value = null
|
||||
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === customerLoadSequence) warehouseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
const sequence = ++locationLoadSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const query: PlatformLocationQuery = {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
zoneCode: appliedFilters.value.zoneCode,
|
||||
locationCode: appliedFilters.value.locationCode,
|
||||
locationType: appliedFilters.value.locationType,
|
||||
status: appliedFilters.value.status,
|
||||
}
|
||||
if (selectedCustomerId.value) query.tenantId = apiId(selectedCustomerId.value)
|
||||
if (selectedWarehouseId.value) query.warehouseId = apiId(selectedWarehouseId.value)
|
||||
const result = await listPlatformLocations(query)
|
||||
if (sequence !== locationLoadSequence) return
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
if (sequence !== locationLoadSequence) return
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = getApiErrorDetails(error, '库位列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === locationLoadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(model: QueryModel) {
|
||||
appliedFilters.value = {
|
||||
zoneCode: String(model.zoneCode || ''),
|
||||
locationCode: String(model.locationCode || ''),
|
||||
locationType: (model.locationType || '') as PlatformLocationType | '',
|
||||
status: (model.status || '') as PlatformLocationStatus | '',
|
||||
}
|
||||
if (page.value === 1) void loadLocations()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function assignForm(resource: PlatformLocationResource) {
|
||||
Object.assign(form, {
|
||||
id: String(resource.id),
|
||||
customerId: String(resource.tenantId),
|
||||
warehouseId: String(resource.warehouseId),
|
||||
zoneCode: resource.zoneCode,
|
||||
zoneName: resource.zoneName,
|
||||
locationCode: resource.locationCode,
|
||||
locationName: resource.locationName ?? '',
|
||||
locationType: resource.locationType,
|
||||
maxWeightKg: resource.maxWeightKg === null ? null : Number(resource.maxWeightKg),
|
||||
status: resource.status as EditablePlatformLocationStatus,
|
||||
remark: resource.remark ?? '',
|
||||
version: resource.version,
|
||||
})
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!requireCreateScope()) return
|
||||
Object.assign(form, emptyForm(), {
|
||||
status: selectedWarehouse.value?.status === 'ACTIVE' ? 'AVAILABLE' : 'DISABLED',
|
||||
})
|
||||
detail.value = null
|
||||
drawerError.value = null
|
||||
drawerMode.value = 'create'
|
||||
captureBaseline()
|
||||
drawerOpen.value = true
|
||||
void formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function loadDetail(row: PlatformLocationResource, mode: 'view' | 'edit') {
|
||||
const scope = recordScope(row)
|
||||
drawerMode.value = mode
|
||||
drawerOpen.value = true
|
||||
drawerLoading.value = true
|
||||
drawerError.value = null
|
||||
detail.value = null
|
||||
try {
|
||||
const result = await getPlatformLocation(scope.customerId, scope.warehouseId, row.id)
|
||||
detail.value = result
|
||||
if (mode === 'edit') {
|
||||
assignForm(result.location)
|
||||
captureBaseline()
|
||||
void formRef.value?.clearValidate()
|
||||
}
|
||||
} catch (error) {
|
||||
drawerError.value = getApiErrorDetails(error, '库位详情加载失败,请稍后重试')
|
||||
} finally {
|
||||
drawerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(): SavePlatformLocationRequest {
|
||||
return {
|
||||
warehouseId: apiId(form.warehouseId),
|
||||
zoneCode: form.zoneCode.trim(),
|
||||
zoneName: form.zoneName.trim(),
|
||||
locationCode: form.locationCode.trim(),
|
||||
locationName: optionalText(form.locationName),
|
||||
locationType: form.locationType,
|
||||
maxWeightKg: form.maxWeightKg,
|
||||
status: form.status,
|
||||
remark: optionalText(form.remark),
|
||||
...(form.id ? { version: form.version } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLocation() {
|
||||
const scope = form.id
|
||||
? { customerId: apiId(form.customerId), warehouseId: apiId(form.warehouseId) }
|
||||
: requireCreateScope()
|
||||
if (!scope) return
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
if (form.id) await updatePlatformLocation(scope.customerId, scope.warehouseId, form.id, payload)
|
||||
else await createPlatformLocation(scope.customerId, payload)
|
||||
drawerOpen.value = false
|
||||
ElMessage.success(form.id ? '库位资料已更新' : '库位已创建')
|
||||
await loadLocations()
|
||||
} catch (error) {
|
||||
actionError(error, '库位保存失败,请检查后重试')
|
||||
if (form.id) await loadLocations()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setActionLoading(id: number | string, active: boolean) {
|
||||
const next = new Set(actionLoadingIds.value)
|
||||
if (active) next.add(String(id))
|
||||
else next.delete(String(id))
|
||||
actionLoadingIds.value = next
|
||||
}
|
||||
|
||||
async function toggleStatus(row: PlatformLocationResource) {
|
||||
const scope = recordScope(row)
|
||||
if (!canChangeStatus(row)) return
|
||||
const nextStatus: EditablePlatformLocationStatus = row.status === 'AVAILABLE' ? 'DISABLED' : 'AVAILABLE'
|
||||
const action = nextStatus === 'AVAILABLE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
nextStatus === 'DISABLED'
|
||||
? `确定停用“${row.locationCode}”吗?停用前后端会再次检查是否存在活动预占或占用。`
|
||||
: `确定重新启用“${row.locationCode}”吗?仅启用仓库中的库位可以恢复可用。`,
|
||||
`${action}库位`,
|
||||
{ type: nextStatus === 'AVAILABLE' ? 'success' : 'warning', confirmButtonText: `确认${action}`, cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
setActionLoading(row.id, true)
|
||||
try {
|
||||
await changePlatformLocationStatus(scope.customerId, scope.warehouseId, row.id, nextStatus, row.version)
|
||||
ElMessage.success(`库位 ${row.locationCode} 已${action}`)
|
||||
await loadLocations()
|
||||
} catch (error) {
|
||||
actionError(error, `${action}库位失败`)
|
||||
await loadLocations()
|
||||
} finally {
|
||||
setActionLoading(row.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLocation(row: PlatformLocationResource) {
|
||||
const scope = recordScope(row)
|
||||
if (!canDelete(row)) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除已停用库位“${row.locationCode}”吗?系统会保留审计记录,此操作不能直接恢复。`,
|
||||
'删除库位',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
setActionLoading(row.id, true)
|
||||
try {
|
||||
await deletePlatformLocation(scope.customerId, scope.warehouseId, row.id, row.version)
|
||||
ElMessage.success(`库位 ${row.locationCode} 已删除`)
|
||||
await loadLocations()
|
||||
} catch (error) {
|
||||
actionError(error, '删除库位失败')
|
||||
await loadLocations()
|
||||
} finally {
|
||||
setActionLoading(row.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoreAction(row: PlatformLocationResource, command: string) {
|
||||
if (command === 'status') void toggleStatus(row)
|
||||
if (command === 'delete') void removeLocation(row)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => access.can(editorPermission.value),
|
||||
(allowed) => {
|
||||
if (allowed || !editorOpen.value) return
|
||||
drawerOpen.value = false
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch([page, pageSize], () => { void loadLocations() })
|
||||
watch(selectedCustomerId, (customerId) => {
|
||||
drawerOpen.value = false
|
||||
loadedCustomerId.value = null
|
||||
warehouses.value = []
|
||||
filters.value = { ...filters.value, warehouseId: '' }
|
||||
if (customerId) void loadWarehouses(customerId)
|
||||
})
|
||||
watch(selectedWarehouseId, () => {
|
||||
drawerOpen.value = false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadCustomers()
|
||||
void loadLocations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page location-page">
|
||||
<QueryToolbar
|
||||
v-model="filters"
|
||||
ariaLabel="库位筛选"
|
||||
:fields="queryFields"
|
||||
@search="applyFilters"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button
|
||||
v-if="access.can('platform:location:create')"
|
||||
type="primary"
|
||||
:disabled="!createReady"
|
||||
@click="openCreate"
|
||||
><Plus :size="16" />新增库位</el-button>
|
||||
</template>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-alert v-if="customerError" type="warning" :closable="false" show-icon class="state-alert">
|
||||
<template #title>所属企业列表加载失败</template>
|
||||
<p>{{ customerError.message }}</p>
|
||||
<ApiErrorMeta :details="customerError" />
|
||||
<el-button size="small" @click="loadCustomers">重新加载所属企业</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-if="warehouseError" type="error" :closable="false" show-icon class="state-alert">
|
||||
<template #title>仓库列表加载失败</template>
|
||||
<p>{{ warehouseError.message }}</p>
|
||||
<ApiErrorMeta :details="warehouseError" />
|
||||
<el-button size="small" @click="loadWarehouses()">重新加载仓库</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-else-if="customerReady && warehouses.length === 0" type="info" :closable="false" show-icon title="当前所属企业暂无仓库">
|
||||
请先在仓库管理中建立仓库,再维护库位。
|
||||
</el-alert>
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
|
||||
<template #title>库位列表加载失败</template>
|
||||
<p>{{ loadError.message }}</p>
|
||||
<ApiErrorMeta :details="loadError" />
|
||||
<el-button size="small" @click="loadLocations()">重新查询</el-button>
|
||||
</el-alert>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100, 200]"
|
||||
row-key="id"
|
||||
aria-label="库位列表"
|
||||
:empty-text="emptyDescription"
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions location-actions">
|
||||
<el-button link type="primary" :disabled="loading" @click="loadDetail(row as PlatformLocationResource, 'view')">
|
||||
<Eye :size="15" />详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canEdit(row as PlatformLocationResource)"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="loading"
|
||||
@click="loadDetail(row as PlatformLocationResource, 'edit')"
|
||||
><Pencil :size="15" />编辑</el-button>
|
||||
<el-dropdown
|
||||
v-if="canChangeStatus(row as PlatformLocationResource) || canDelete(row as PlatformLocationResource)"
|
||||
trigger="click"
|
||||
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
|
||||
@command="handleMoreAction(row as PlatformLocationResource, String($event))"
|
||||
>
|
||||
<el-button link type="primary" aria-label="更多库位操作"><MoreHorizontal :size="16" />更多</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="canChangeStatus(row as PlatformLocationResource)" command="status">
|
||||
<Power :size="15" />{{ (row as PlatformLocationResource).status === 'AVAILABLE' ? '停用' : '启用' }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canDelete(row as PlatformLocationResource)" command="delete" divided>
|
||||
<Trash2 :size="15" />删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template #location="{ row }">
|
||||
<div class="location-cell">
|
||||
<span><MapPin :size="17" /></span>
|
||||
<div>
|
||||
<strong>{{ (row as PlatformLocationResource).locationCode }}</strong>
|
||||
<small>{{ (row as PlatformLocationResource).locationName || '未命名库位' }}</small>
|
||||
</div>
|
||||
<el-tag v-if="(row as PlatformLocationResource).sharedOverflow" size="small" type="warning">系统位</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #zone="{ row }">
|
||||
<div class="stacked-value"><strong>{{ (row as PlatformLocationResource).zoneName }}</strong><small>{{ (row as PlatformLocationResource).zoneCode }}</small></div>
|
||||
</template>
|
||||
<template #warehouse="{ row }">
|
||||
<div class="stacked-value">
|
||||
<strong>{{ (row as PlatformLocationResource).warehouseName || selectedWarehouse?.warehouseName || '—' }}</strong>
|
||||
<small>{{ (row as PlatformLocationResource).warehouseCode || selectedWarehouse?.warehouseCode || '—' }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<template #locationType="{ row }">{{ typeLabels[(row as PlatformLocationResource).locationType] }}</template>
|
||||
<template #maxWeightKg="{ row }">{{ formatWeight((row as PlatformLocationResource).maxWeightKg) }}</template>
|
||||
<template #status="{ row }">
|
||||
<el-tag :type="statusMeta[(row as PlatformLocationResource).status].type" effect="light">
|
||||
{{ statusMeta[(row as PlatformLocationResource).status].label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template #remark="{ row }"><span class="remark-cell">{{ (row as PlatformLocationResource).remark || '—' }}</span></template>
|
||||
<template #empty>
|
||||
<el-empty v-if="!loading" :description="emptyDescription">
|
||||
<el-button
|
||||
v-if="selectedWarehouseId && !filtered && access.can('platform:location:create')"
|
||||
type="primary"
|
||||
@click="openCreate"
|
||||
><Plus :size="16" />新增第一个库位</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个库位</span></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<el-drawer
|
||||
v-model="drawerOpen"
|
||||
:title="drawerTitle"
|
||||
size="min(620px, 100vw)"
|
||||
class="business-drawer location-drawer"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!saving"
|
||||
:show-close="!saving"
|
||||
>
|
||||
<div v-loading="drawerLoading" class="drawer-content">
|
||||
<el-alert v-if="drawerError" type="error" :closable="false" show-icon title="库位详情加载失败">
|
||||
<p>{{ drawerError.message }}</p>
|
||||
<ApiErrorMeta :details="drawerError" />
|
||||
</el-alert>
|
||||
|
||||
<template v-else-if="drawerMode === 'view' && detail">
|
||||
<section class="detail-identity">
|
||||
<div class="detail-identity__icon"><MapPin :size="24" /></div>
|
||||
<div>
|
||||
<h3>{{ detail.location.locationCode }}</h3>
|
||||
<p>{{ detail.location.locationName || '未命名库位' }}</p>
|
||||
</div>
|
||||
<el-tag :type="statusMeta[detail.location.status].type">{{ statusMeta[detail.location.status].label }}</el-tag>
|
||||
</section>
|
||||
<el-descriptions :column="2" border class="detail-descriptions">
|
||||
<el-descriptions-item label="所属企业">{{ customerLabel(detail.location.tenantId) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属仓库">{{ detail.warehouseName }}({{ detail.warehouseCode }})</el-descriptions-item>
|
||||
<el-descriptions-item label="库位编码">{{ detail.location.locationCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="库位名称">{{ detail.location.locationName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="库区">{{ detail.location.zoneName }}({{ detail.location.zoneCode }})</el-descriptions-item>
|
||||
<el-descriptions-item label="库位类型">{{ typeLabels[detail.location.locationType] }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最大承重">{{ formatWeight(detail.location.maxWeightKg) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusMeta[detail.location.status].label }}</el-descriptions-item>
|
||||
<el-descriptions-item label="资源版本">v{{ detail.location.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.location.updatedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="系统共享位">{{ detail.location.sharedOverflow ? '是,仅系统自动分配' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ detail.location.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert
|
||||
v-if="detail.location.status === 'RESERVED' || detail.location.status === 'OCCUPIED'"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前库位正在被业务使用,只允许查看。"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="detail.location.sharedOverflow"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="A0 是系统共享溢出位,不能编辑、停用或删除。"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
v-else-if="drawerMode !== 'view'"
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="drawer-form"
|
||||
:disabled="saving"
|
||||
>
|
||||
<div class="editor-scope">
|
||||
<MapPin :size="18" />
|
||||
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>所属企业:{{ customerLabel(form.customerId) }} · 保存后不可跨仓移动</span></div>
|
||||
</div>
|
||||
<section class="form-section">
|
||||
<h3>库位标识</h3>
|
||||
<p class="section-description">库位编码在当前企业和仓库内唯一;编辑后不能移动到其他仓库。</p>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="库位编码" prop="locationCode" required>
|
||||
<el-input v-model.trim="form.locationCode" maxlength="50" placeholder="例如 A1-01" />
|
||||
</el-form-item>
|
||||
<el-form-item label="库位名称">
|
||||
<el-input v-model.trim="form.locationName" maxlength="100" placeholder="例如 A1 区 01 位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="库区编码" prop="zoneCode" required>
|
||||
<el-input v-model.trim="form.zoneCode" maxlength="50" placeholder="例如 A" />
|
||||
</el-form-item>
|
||||
<el-form-item label="库区名称" prop="zoneName" required>
|
||||
<el-input v-model.trim="form.zoneName" maxlength="100" placeholder="例如 常规区" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>资源属性</h3>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="库位类型" prop="locationType" required>
|
||||
<el-select v-model="form.locationType" placeholder="请选择库位类型">
|
||||
<el-option label="货架位" value="SHELF" />
|
||||
<el-option label="托盘位" value="PALLET" />
|
||||
<el-option label="地堆位" value="FLOOR" />
|
||||
<el-option label="流利架" value="FLOW_RACK" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大承重(kg)">
|
||||
<el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" :step="10" controls-position="right" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="drawerMode === 'create'" label="初始状态" prop="status" required>
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio-button value="AVAILABLE" :disabled="selectedWarehouse?.status !== 'ACTIVE'">可用</el-radio-button>
|
||||
<el-radio-button value="DISABLED">停用</el-radio-button>
|
||||
</el-radio-group>
|
||||
<p v-if="selectedWarehouse?.status !== 'ACTIVE'" class="form-help">当前仓库已停用,新库位只能以停用状态创建。</p>
|
||||
</el-form-item>
|
||||
<div v-else class="readonly-status">
|
||||
<span>当前状态</span>
|
||||
<el-tag :type="statusMeta[form.status].type">{{ statusMeta[form.status].label }}</el-tag>
|
||||
<small>状态只能通过列表中的启停操作变更。</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>备注</h3>
|
||||
<el-form-item label="内部说明">
|
||||
<el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入库位位置、使用限制或其他说明" />
|
||||
</el-form-item>
|
||||
</section>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'view' ? '关闭' : '取消' }}</el-button>
|
||||
<el-button v-if="drawerMode !== 'view'" type="primary" :loading="saving" @click="saveLocation">保存库位</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.state-alert p { margin: 6px 0 0; line-height: 1.55; }
|
||||
.state-alert .el-button { margin-top: 10px; }
|
||||
.location-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.location-actions :deep(.el-button) { margin-left: 0; }
|
||||
.location-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.location-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
|
||||
.location-cell > div, .stacked-value { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.location-cell strong, .stacked-value strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.location-cell small, .stacked-value small { color: var(--cc-muted); font-size: 11px; }
|
||||
.remark-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.drawer-content { min-height: 260px; }
|
||||
.drawer-content > .el-alert + .el-alert, .detail-descriptions + .el-alert { margin-top: 14px; }
|
||||
.detail-identity { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 12px; margin-bottom: 18px; }
|
||||
.detail-identity__icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 10px; background: #eef3fa; color: var(--cc-primary); }
|
||||
.detail-identity h3 { margin: 0; font-size: 18px; }
|
||||
.detail-identity p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-descriptions :deep(.el-descriptions__label) { width: 118px; }
|
||||
.section-description { margin: -3px 0 14px; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.drawer-form :deep(.el-input-number), .drawer-form :deep(.el-select) { width: 100%; }
|
||||
.editor-scope { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; padding: 12px; background: #f7f9fc; color: var(--cc-primary); }
|
||||
.editor-scope div { display: grid; min-width: 0; gap: 2px; }
|
||||
.editor-scope strong, .editor-scope span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.editor-scope span { color: var(--cc-muted); font-size: 12px; }
|
||||
.form-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.readonly-status { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 12px; background: #f7f9fc; }
|
||||
.readonly-status > span { color: var(--cc-text); font-size: 13px; font-weight: 600; }
|
||||
.readonly-status small { color: var(--cc-muted); font-size: 11px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-identity { grid-template-columns: 44px minmax(0, 1fr); }
|
||||
.detail-identity > .el-tag { grid-column: 2; justify-self: start; }
|
||||
.detail-descriptions :deep(.el-descriptions__body), .detail-descriptions :deep(table), .detail-descriptions :deep(tbody) { display: block; width: 100%; }
|
||||
.detail-descriptions :deep(tr) { display: grid; grid-template-columns: 108px minmax(0, 1fr); }
|
||||
.detail-descriptions :deep(.el-descriptions__cell) { display: block; width: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
createPlatformPallet,
|
||||
deletePlatformPallet,
|
||||
getPlatformPallet,
|
||||
listPlatformPallets,
|
||||
updatePlatformPallet,
|
||||
type SavePlatformPalletRequest,
|
||||
} from './platformPallets'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const payload: SavePlatformPalletRequest = {
|
||||
warehouseId: '19001',
|
||||
palletCode: ' TP-001 ',
|
||||
palletType: '木托盘',
|
||||
capacityCbm: 1.2,
|
||||
remark: null,
|
||||
}
|
||||
|
||||
describe('platform pallet API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
vi.mocked(http.delete).mockReset()
|
||||
})
|
||||
|
||||
it('uses the real page contract and normalizes long pagination fields', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '41', pages: '3' },
|
||||
})
|
||||
|
||||
await expect(listPlatformPallets({
|
||||
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '19001', palletCode: ' TP ', palletType: ' 木托盘 ',
|
||||
})).resolves.toEqual({ records: [], page: 2, size: 20, total: 41, pages: 3 })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets', {
|
||||
params: {
|
||||
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '19001', palletCode: 'TP', palletType: '木托盘',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the customer parameter for an all-customer page query', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
|
||||
})
|
||||
|
||||
await listPlatformPallets({ page: 2, size: 20 })
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets', {
|
||||
params: { page: 2, size: 20 },
|
||||
})
|
||||
expect(vi.mocked(http.get).mock.calls[0]?.[1]?.params).not.toHaveProperty('tenantId')
|
||||
})
|
||||
|
||||
it('uses tenant and warehouse scope for detail, update, and delete', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { id: '20001' } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: '20001' })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: null })
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: null })
|
||||
|
||||
await expect(getPlatformPallet(10001, '19001', '20001')).resolves.toEqual({ id: '20001' })
|
||||
await expect(createPlatformPallet(10001, payload)).resolves.toBe('20001')
|
||||
await expect(updatePlatformPallet(10001, '19001', '20001', payload)).resolves.toBeNull()
|
||||
await expect(deletePlatformPallet(10001, '19001', '20001')).resolves.toBeNull()
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets/20001', { params: { tenantId: 10001, warehouseId: '19001' } })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/pallets', payload, { params: { tenantId: 10001 } })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/pallets/20001', payload, { params: { tenantId: 10001, warehouseId: '19001' } })
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/pallets/20001', { params: { tenantId: 10001, warehouseId: '19001' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface PlatformPalletResource {
|
||||
id: number | string
|
||||
tenantId: number | string
|
||||
warehouseId: number | string
|
||||
palletCode: string
|
||||
palletType: string
|
||||
capacityCbm: number | string
|
||||
remark: string | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface PlatformPalletPage {
|
||||
records: PlatformPalletResource[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface PlatformPalletQuery {
|
||||
tenantId?: number | string
|
||||
page: number
|
||||
size: number
|
||||
warehouseId?: number | string
|
||||
palletCode?: string
|
||||
palletType?: string
|
||||
}
|
||||
|
||||
export interface SavePlatformPalletRequest {
|
||||
warehouseId: number | string
|
||||
palletCode: string
|
||||
palletType: string
|
||||
capacityCbm: number
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/pallets'
|
||||
|
||||
export async function listPlatformPallets(query: PlatformPalletQuery) {
|
||||
const response = await http.get<ApiEnvelope<PlatformPalletPage>, ApiEnvelope<PlatformPalletPage>>(basePath, {
|
||||
params: {
|
||||
...(query.tenantId === undefined || query.tenantId === '' ? {} : { tenantId: query.tenantId }),
|
||||
page: query.page,
|
||||
size: query.size,
|
||||
...(query.warehouseId === undefined || query.warehouseId === '' ? {} : { warehouseId: query.warehouseId }),
|
||||
...(query.palletCode?.trim() ? { palletCode: query.palletCode.trim() } : {}),
|
||||
...(query.palletType?.trim() ? { palletType: query.palletType.trim() } : {}),
|
||||
},
|
||||
})
|
||||
const result = unwrapApiResponse(response)
|
||||
return {
|
||||
...result,
|
||||
page: Number(result.page),
|
||||
size: Number(result.size),
|
||||
total: Number(result.total),
|
||||
pages: Number(result.pages),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformPallet(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
) {
|
||||
const response = await http.get<ApiEnvelope<PlatformPalletResource>, ApiEnvelope<PlatformPalletResource>>(
|
||||
`${basePath}/${id}`,
|
||||
{ params: { tenantId, warehouseId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function createPlatformPallet(tenantId: number | string, payload: SavePlatformPalletRequest) {
|
||||
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload, {
|
||||
params: { tenantId },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updatePlatformPallet(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
payload: SavePlatformPalletRequest,
|
||||
) {
|
||||
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, payload, {
|
||||
params: { tenantId, warehouseId },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function deletePlatformPallet(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
) {
|
||||
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, {
|
||||
params: { tenantId, warehouseId },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants as listPlatformCustomers, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import {
|
||||
createPlatformPallet,
|
||||
getPlatformPallet,
|
||||
listPlatformPallets,
|
||||
type PlatformPalletResource,
|
||||
} from '../api/platformPallets'
|
||||
import PalletsView from './PalletsView.vue'
|
||||
|
||||
vi.mock('@/modules/iam', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/modules/iam')>()
|
||||
const listPlatformTenants = vi.fn<typeof actual.listPlatformTenants>()
|
||||
return { ...actual, listPlatformTenants }
|
||||
})
|
||||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformPallets', () => ({
|
||||
listPlatformPallets: vi.fn(),
|
||||
getPlatformPallet: vi.fn(),
|
||||
createPlatformPallet: vi.fn(),
|
||||
updatePlatformPallet: vi.fn(),
|
||||
deletePlatformPallet: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
const customer = (id: number | string, tenantName: string): PlatformCustomerResource => ({
|
||||
id, tenantCode: String(id), tenantName, status: 'ACTIVE', legalName: tenantName,
|
||||
legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null,
|
||||
contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null,
|
||||
salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null,
|
||||
registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null,
|
||||
internalRemark: null, createdAt: null, approvedAt: null,
|
||||
})
|
||||
|
||||
const warehouse: PlatformWarehouseResource = {
|
||||
id: '19001', tenantId: 10001, warehouseCode: 'CN-YW-01', warehouseName: '义乌收货仓',
|
||||
warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null,
|
||||
contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null,
|
||||
}
|
||||
|
||||
const pallet: PlatformPalletResource = {
|
||||
id: '20001', tenantId: 10001, warehouseId: '19001', palletCode: 'TP-001', palletType: '木托盘',
|
||||
capacityCbm: '1.200000', remark: '标准川字托盘', createdAt: '2026-08-04T09:00:00', updatedAt: '2026-08-04T10:00:00',
|
||||
}
|
||||
|
||||
async function mountView(permissions = [
|
||||
'platform:pallet:list', 'platform:pallet:create', 'platform:pallet:edit', 'platform:pallet:delete',
|
||||
]) {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function setFilterContext(wrapper: VueWrapper, customerId = '10001', warehouseId = '') {
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
|
||||
customerId,
|
||||
warehouseId: '',
|
||||
palletCode: '',
|
||||
palletType: '',
|
||||
})
|
||||
await flushPromises()
|
||||
if (!warehouseId) return
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
|
||||
customerId,
|
||||
warehouseId,
|
||||
palletCode: '',
|
||||
palletType: '',
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function loadRows(wrapper: VueWrapper, warehouseId = '') {
|
||||
await setFilterContext(wrapper, '10001', warehouseId)
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
|
||||
customerId: '10001',
|
||||
warehouseId,
|
||||
palletCode: '',
|
||||
palletType: '',
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('PalletsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(listPlatformCustomers).mockReset().mockResolvedValue({
|
||||
records: [customer(10001, '默认企业'), customer('9223372036854775806', '雪花企业')], page: 1, size: 200, total: 2, pages: 1,
|
||||
})
|
||||
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listPlatformPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformPallet).mockReset().mockResolvedValue(pallet)
|
||||
vi.mocked(createPlatformPallet).mockReset().mockResolvedValue('20002')
|
||||
})
|
||||
|
||||
it('starts with all customers and uses compact semantic filter widths', async () => {
|
||||
const wrapper = await mountView()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const fields = toolbar.props('fields')
|
||||
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ customerId: '', warehouseId: '' })
|
||||
expect(fields.map((field) => [field.key, field.width])).toEqual([
|
||||
['customerId', 260],
|
||||
['warehouseId', 240],
|
||||
['palletCode', 240],
|
||||
['palletType', 180],
|
||||
])
|
||||
expect(wrapper.find('.pallet-filter-toolbar').exists()).toBe(true)
|
||||
expect(fields.find((field) => field.key === 'customerId')?.placeholder).toBe('全部客户')
|
||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||
page: 1, size: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads warehouses after customer selection and omits an empty warehouse id when searching', async () => {
|
||||
const wrapper = await mountView()
|
||||
await loadRows(wrapper)
|
||||
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(10001, 1, 200)
|
||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||
page: 1, size: 20, tenantId: 10001,
|
||||
})
|
||||
expect(wrapper.text()).toContain('TP-001')
|
||||
expect(wrapper.text()).toContain('木托盘')
|
||||
expect(wrapper.text()).toContain('1.2 m³')
|
||||
expect(wrapper.text()).toContain('义乌收货仓')
|
||||
expect(wrapper.text()).toContain('托盘列表')
|
||||
expect(wrapper.text()).not.toContain('启停托盘')
|
||||
expect(wrapper.text()).not.toContain('全部状态')
|
||||
expect(wrapper.text()).toContain('全部托盘类型')
|
||||
expect(wrapper.find('input[aria-label="所属企业"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[aria-label="所属仓库"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.platform-tenant-selector').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('租户')
|
||||
expect(wrapper.find('.data-table').exists()).toBe(true)
|
||||
expect(wrapper.find('.table-actions.pallet-row-actions').exists()).toBe(true)
|
||||
expect(wrapper.findAll('thead th').map((item) => item.text().trim())[0]).toBe('操作')
|
||||
})
|
||||
|
||||
it('queries all customers explicitly without adding a customer id', async () => {
|
||||
vi.mocked(listPlatformPallets).mockResolvedValue({ records: [pallet], page: 1, size: 20, total: 41, pages: 3 })
|
||||
const wrapper = await mountView()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
|
||||
toolbar.vm.$emit('search', { customerId: '', warehouseId: '', palletCode: '', palletType: '' })
|
||||
await flushPromises()
|
||||
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
||||
page: 1, size: 20,
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
it('resets customer and dependent warehouse filters without sending an empty id', async () => {
|
||||
const wrapper = await mountView()
|
||||
await setFilterContext(wrapper, '10001', '19001')
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
|
||||
toolbar.vm.reset()
|
||||
await flushPromises()
|
||||
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ customerId: '', warehouseId: '' })
|
||||
expect(listPlatformPallets).toHaveBeenCalledTimes(2)
|
||||
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
||||
page: 1, size: 20,
|
||||
})
|
||||
expect(toolbar.props('fields').find((field) => field.key === 'customerId')?.placeholder).toBe('全部客户')
|
||||
})
|
||||
|
||||
it('preserves a snowflake customer id when switching the enterprise filter', async () => {
|
||||
const wrapper = await mountView()
|
||||
const snowflakeId = '9223372036854775806'
|
||||
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
|
||||
customerId: snowflakeId,
|
||||
warehouseId: '',
|
||||
palletCode: '',
|
||||
palletType: '',
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(snowflakeId, 1, 200)
|
||||
})
|
||||
|
||||
it('applies pallet filters only after search', async () => {
|
||||
const wrapper = await mountView()
|
||||
await setFilterContext(wrapper, '10001', '19001')
|
||||
await wrapper.find('input[placeholder="托盘编码(模糊查询)"]').setValue('TP-00')
|
||||
expect(listPlatformPallets).toHaveBeenCalledTimes(1)
|
||||
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
|
||||
customerId: '10001', warehouseId: '19001', palletCode: 'TP-00', palletType: '木托盘',
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
||||
page: 1, size: 20, tenantId: 10001, warehouseId: 19001, palletCode: 'TP-00', palletType: '木托盘',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a pallet in the selected warehouse with the real request fields', async () => {
|
||||
const wrapper = await mountView()
|
||||
await setFilterContext(wrapper, '10001', '19001')
|
||||
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增托盘')
|
||||
await createButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input[placeholder="例如 TP-001"]').setValue('TP-002')
|
||||
const palletTypeItem = wrapper.findAll('.el-form-item').find((item) => item.text().includes('托盘类型'))
|
||||
palletTypeItem?.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '塑料托盘')
|
||||
const capacity = wrapper.findComponent({ name: 'ElInputNumber' })
|
||||
capacity.vm.$emit('update:modelValue', 1.5)
|
||||
await wrapper.find('textarea[placeholder="可填写规格、材质或现场识别说明"]').setValue('蓝色标准托盘')
|
||||
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === '保存托盘')
|
||||
await saveButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(createPlatformPallet).toHaveBeenCalledWith(10001, {
|
||||
warehouseId: 19001, palletCode: 'TP-002', palletType: '塑料托盘', capacityCbm: 1.5, remark: '蓝色标准托盘',
|
||||
})
|
||||
})
|
||||
|
||||
it('hides all write actions without write permissions', async () => {
|
||||
const wrapper = await mountView(['platform:pallet:list'])
|
||||
|
||||
expect(wrapper.find('.panel-heading button').exists()).toBe(false)
|
||||
const actions = wrapper.find('.pallet-row-actions').text()
|
||||
expect(actions).not.toContain('编辑')
|
||||
expect(actions).not.toContain('删除')
|
||||
expect(actions).toContain('查看')
|
||||
})
|
||||
|
||||
it('shows a recoverable list failure instead of demo data', async () => {
|
||||
vi.mocked(listPlatformPallets).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.text()).toContain('托盘列表暂时无法加载')
|
||||
expect(wrapper.text()).toContain('Gateway unavailable')
|
||||
expect(wrapper.text()).toContain('重新加载')
|
||||
expect(wrapper.text()).not.toContain('TP-001')
|
||||
})
|
||||
|
||||
it('stops dependent requests when the enterprise filter cannot load', async () => {
|
||||
vi.mocked(listPlatformCustomers).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.text()).toContain('所属企业列表暂时无法加载')
|
||||
expect(wrapper.text()).toContain('重新加载')
|
||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||
page: 1, size: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows only the pallet fields exposed by the backend contract', async () => {
|
||||
const crossCustomerPallet = { ...pallet, tenantId: '9223372036854775806' }
|
||||
vi.mocked(listPlatformPallets).mockResolvedValue({ records: [crossCustomerPallet], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformPallet).mockResolvedValue(crossCustomerPallet)
|
||||
const wrapper = await mountView()
|
||||
await loadRows(wrapper)
|
||||
expect(wrapper.findComponent(DataTable).props('data')).toEqual([crossCustomerPallet])
|
||||
const tableBody = wrapper.find('.el-table__body-wrapper')
|
||||
const viewButton = tableBody.findAll('.pallet-row-actions button').find((button) => button.text().trim() === '查看')
|
||||
await viewButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getPlatformPallet).toHaveBeenCalledWith('9223372036854775806', 19001, '20001')
|
||||
expect(document.body.textContent).toContain('所属企业')
|
||||
expect(document.body.textContent).toContain('雪花企业(9223372036854775806)')
|
||||
expect(document.body.textContent).toContain('托盘编码')
|
||||
expect(document.body.textContent).toContain('托盘类型')
|
||||
expect(document.body.textContent).toContain('托盘容积')
|
||||
expect(document.body.textContent).toContain('归属信息')
|
||||
expect(document.body.textContent).toContain('托盘规格')
|
||||
expect(document.body.textContent).toContain('记录信息')
|
||||
expect(document.body.textContent).not.toContain('企业 ID')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
expect(document.body.textContent).not.toContain('托盘状态')
|
||||
expect(document.body.textContent).not.toContain('最大承重')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,565 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Boxes, Eye, Pencil, Plus, Trash2, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { listPlatformTenants as listPlatformCustomers, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import {
|
||||
createPlatformPallet,
|
||||
deletePlatformPallet,
|
||||
getPlatformPallet,
|
||||
listPlatformPallets,
|
||||
updatePlatformPallet,
|
||||
type PlatformPalletResource,
|
||||
type SavePlatformPalletRequest,
|
||||
} from '../api/platformPallets'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformCustomerResource[]>([])
|
||||
const customerLoading = ref(false)
|
||||
const customerError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedCustomerId = ref('')
|
||||
const selectedWarehouseId = ref('')
|
||||
const warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const warehouseLoading = ref(false)
|
||||
const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const rows = ref<PlatformPalletResource[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const editorOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const detail = ref<PlatformPalletResource | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deletingIds = ref(new Set<string>())
|
||||
const formRef = ref<FormInstance>()
|
||||
let warehouseSequence = 0
|
||||
let palletSequence = 0
|
||||
|
||||
type PalletFilters = QueryModel & { customerId: string; warehouseId: string; palletCode: string; palletType: string }
|
||||
type AppliedPalletFilters = Pick<PalletFilters, 'palletCode' | 'palletType'>
|
||||
const filters = ref<PalletFilters>({ customerId: '', warehouseId: '', palletCode: '', palletType: '' })
|
||||
const appliedFilters = ref<AppliedPalletFilters>({ palletCode: '', palletType: '' })
|
||||
const palletTypeOptions = ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸托盘']
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'customerId',
|
||||
type: 'select',
|
||||
width: 260,
|
||||
placeholder: customerLoading.value ? '正在加载客户' : customerError.value ? '客户列表加载失败' : '全部客户',
|
||||
options: customers.value.map((customer) => ({
|
||||
label: `${customer.tenantName}(${customer.tenantCode || customer.id})`,
|
||||
value: String(customer.id),
|
||||
})),
|
||||
props: { filterable: true, disabled: contextLocked.value || customerLoading.value || Boolean(customerError.value), 'aria-label': '所属企业' },
|
||||
},
|
||||
{
|
||||
key: 'warehouseId',
|
||||
type: 'select',
|
||||
width: 240,
|
||||
placeholder: warehouseLoading.value ? '正在加载所属仓库' : warehouseError.value ? '所属仓库加载失败' : '全部仓库',
|
||||
options: warehouses.value.map((warehouse) => ({
|
||||
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
|
||||
value: String(warehouse.id),
|
||||
disabled: warehouse.status !== 'ACTIVE',
|
||||
})),
|
||||
props: {
|
||||
filterable: true,
|
||||
disabled: contextLocked.value || !selectedCustomerId.value || warehouseLoading.value || Boolean(warehouseError.value),
|
||||
'aria-label': '所属仓库',
|
||||
},
|
||||
},
|
||||
{ key: 'palletCode', type: 'input', width: 240, placeholder: '托盘编码(模糊查询)', props: { maxlength: 50, 'aria-label': '托盘编码' } },
|
||||
{
|
||||
key: 'palletType',
|
||||
type: 'select',
|
||||
width: 180,
|
||||
placeholder: '全部托盘类型',
|
||||
options: palletTypeOptions.map((value) => ({ label: value, value })),
|
||||
props: { filterable: true, allowCreate: true, defaultFirstOption: true, 'aria-label': '托盘类型' },
|
||||
},
|
||||
])
|
||||
|
||||
const emptyForm = () => ({ id: '', customerId: '', warehouseId: '', palletCode: '', palletType: '', capacityCbm: null as number | null, remark: '' })
|
||||
const form = reactive(emptyForm())
|
||||
const rules: FormRules<typeof form> = {
|
||||
palletCode: [
|
||||
{ required: true, message: '请输入托盘编码', trigger: 'blur' },
|
||||
{ max: 50, message: '托盘编码不能超过 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
palletType: [
|
||||
{ required: true, message: '请输入托盘类型', trigger: 'blur' },
|
||||
{ max: 32, message: '托盘类型不能超过 32 个字符', trigger: 'blur' },
|
||||
],
|
||||
capacityCbm: [{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) callback(new Error('请输入大于 0 的托盘容积'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'change',
|
||||
}],
|
||||
remark: [{ max: 500, message: '备注不能超过 500 个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const currentWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === selectedWarehouseId.value) || null)
|
||||
const editorWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === form.warehouseId) || null)
|
||||
const currentCustomer = computed(() => customers.value.find((item) => String(item.id) === selectedCustomerId.value) || null)
|
||||
const contextReady = computed(() => Boolean(selectedCustomerId.value && selectedWarehouseId.value && currentWarehouse.value))
|
||||
const editorTitle = computed(() => form.id ? '编辑托盘' : '新增托盘')
|
||||
const editorPermission = computed(() => form.id ? 'platform:pallet:edit' : 'platform:pallet:create')
|
||||
const contextLocked = computed(() => editorOpen.value || detailOpen.value || saving.value || deletingIds.value.size > 0)
|
||||
const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim()))
|
||||
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'pallet-editor',
|
||||
label: editorTitle,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: editorPermission,
|
||||
})
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', minWidth: 224, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'pallet', label: '托盘编码', minWidth: 190, slotName: 'pallet' },
|
||||
{ prop: 'palletType', label: '托盘类型', minWidth: 140 },
|
||||
{ key: 'capacity', label: '托盘容积(m³)', minWidth: 160, slotName: 'capacity' },
|
||||
{ key: 'warehouse', label: '所属仓库', minWidth: 210, slotName: 'warehouse' },
|
||||
{ key: 'remark', label: '备注', minWidth: 220, slotName: 'remark' },
|
||||
{ prop: 'updatedAt', label: '更新时间', minWidth: 160, formatter: (row: PlatformPalletResource) => formatDateTime(row.updatedAt) },
|
||||
]
|
||||
|
||||
function apiId(value: string) {
|
||||
const numeric = Number(value)
|
||||
return Number.isSafeInteger(numeric) ? numeric : value
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
return value?.replace('T', ' ').slice(0, 16) || '—'
|
||||
}
|
||||
|
||||
function formatCapacity(value: number | string) {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return String(value)
|
||||
return `${numeric.toLocaleString('zh-CN', { maximumFractionDigits: 6 })} m³`
|
||||
}
|
||||
|
||||
function warehouseFor(warehouseId: number | string) {
|
||||
return warehouses.value.find((item) => String(item.id) === String(warehouseId)) || null
|
||||
}
|
||||
|
||||
function customerLabel(customerId: number | string) {
|
||||
const customer = customers.value.find((item) => String(item.id) === String(customerId))
|
||||
return customer ? `${customer.tenantName}(${customer.tenantCode || customer.id})` : '—'
|
||||
}
|
||||
|
||||
function errorMessage(details: ApiErrorDetails) {
|
||||
return details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
customerLoading.value = true
|
||||
customerError.value = null
|
||||
try {
|
||||
const records: PlatformCustomerResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listPlatformCustomers({ page: currentPage, size: 200 })
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
customers.value = records
|
||||
} catch (error) {
|
||||
customers.value = []
|
||||
customerError.value = getApiErrorDetails(error, '所属企业列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
customerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWarehouses(customerId = selectedCustomerId.value) {
|
||||
const sequence = ++warehouseSequence
|
||||
selectedWarehouseId.value = ''
|
||||
filters.value = { ...filters.value, warehouseId: '' }
|
||||
warehouses.value = []
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
warehouseError.value = null
|
||||
if (!customerId) return
|
||||
warehouseLoading.value = true
|
||||
try {
|
||||
const result = await listPlatformWarehouses(apiId(customerId), 1, 200)
|
||||
if (sequence !== warehouseSequence) return
|
||||
warehouses.value = result.records
|
||||
} catch (error) {
|
||||
if (sequence !== warehouseSequence) return
|
||||
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === warehouseSequence) warehouseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPallets() {
|
||||
const customerId = selectedCustomerId.value
|
||||
const warehouseId = selectedWarehouseId.value
|
||||
const sequence = ++palletSequence
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listPlatformPallets({
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
...(customerId ? { tenantId: apiId(customerId) } : {}),
|
||||
...(warehouseId ? { warehouseId: apiId(warehouseId) } : {}),
|
||||
...(appliedFilters.value.palletCode ? { palletCode: appliedFilters.value.palletCode } : {}),
|
||||
...(appliedFilters.value.palletType ? { palletType: appliedFilters.value.palletType } : {}),
|
||||
})
|
||||
if (sequence !== palletSequence) return
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
if (sequence !== palletSequence) return
|
||||
loadError.value = getApiErrorDetails(error, '托盘列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === palletSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(model: QueryModel) {
|
||||
selectedCustomerId.value = String(model.customerId || '')
|
||||
selectedWarehouseId.value = String(model.warehouseId || '')
|
||||
appliedFilters.value = { palletCode: String(model.palletCode || ''), palletType: String(model.palletType || '') }
|
||||
if (page.value === 1) void loadPallets()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
selectedCustomerId.value = ''
|
||||
selectedWarehouseId.value = ''
|
||||
appliedFilters.value = { palletCode: '', palletType: '' }
|
||||
if (page.value === 1) void loadPallets()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!contextReady.value) {
|
||||
ElMessage.warning('请先选择所属企业和仓库')
|
||||
return
|
||||
}
|
||||
Object.assign(form, emptyForm())
|
||||
form.customerId = selectedCustomerId.value
|
||||
form.warehouseId = selectedWarehouseId.value
|
||||
editorOpen.value = true
|
||||
captureBaseline()
|
||||
void formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function assignForm(resource: PlatformPalletResource) {
|
||||
Object.assign(form, {
|
||||
id: String(resource.id),
|
||||
customerId: String(resource.tenantId),
|
||||
warehouseId: String(resource.warehouseId),
|
||||
palletCode: resource.palletCode,
|
||||
palletType: resource.palletType,
|
||||
capacityCbm: Number(resource.capacityCbm),
|
||||
remark: resource.remark || '',
|
||||
})
|
||||
editorOpen.value = true
|
||||
captureBaseline()
|
||||
void formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function fetchDetail(row: PlatformPalletResource) {
|
||||
return getPlatformPallet(apiId(String(row.tenantId)), apiId(String(row.warehouseId)), row.id)
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformPalletResource) {
|
||||
detailLoading.value = true
|
||||
detailOpen.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
detail.value = await fetchDetail(row)
|
||||
} catch (error) {
|
||||
detailOpen.value = false
|
||||
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘详情加载失败,请稍后重试')))
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(row: PlatformPalletResource) {
|
||||
try {
|
||||
assignForm(await fetchDetail(row))
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘详情加载失败,请稍后重试')))
|
||||
}
|
||||
}
|
||||
|
||||
async function savePallet() {
|
||||
if ((!form.id && !contextReady.value) || !form.customerId || !form.warehouseId || !await formRef.value?.validate().catch(() => false) || form.capacityCbm === null) return
|
||||
saving.value = true
|
||||
const payload: SavePlatformPalletRequest = {
|
||||
warehouseId: apiId(form.warehouseId),
|
||||
palletCode: form.palletCode.trim(),
|
||||
palletType: form.palletType.trim(),
|
||||
capacityCbm: form.capacityCbm,
|
||||
remark: form.remark.trim() || null,
|
||||
}
|
||||
try {
|
||||
const customerId = apiId(form.customerId)
|
||||
if (form.id) await updatePlatformPallet(customerId, apiId(form.warehouseId), form.id, payload)
|
||||
else await createPlatformPallet(customerId, payload)
|
||||
ElMessage.success(form.id ? '托盘已更新' : '托盘已新增')
|
||||
editorOpen.value = false
|
||||
await loadPallets()
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘保存失败,请稍后重试')))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePallet(row: PlatformPalletResource) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除托盘“${row.palletCode}”吗?已预占或已占用的托盘无法删除。`,
|
||||
'删除托盘',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'el-button--danger' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const id = String(row.id)
|
||||
deletingIds.value = new Set([...deletingIds.value, id])
|
||||
try {
|
||||
await deletePlatformPallet(apiId(String(row.tenantId)), apiId(String(row.warehouseId)), row.id)
|
||||
ElMessage.success('托盘已删除')
|
||||
if (rows.value.length === 1 && page.value > 1) page.value -= 1
|
||||
else await loadPallets()
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘删除失败,请稍后重试')))
|
||||
} finally {
|
||||
const next = new Set(deletingIds.value)
|
||||
next.delete(id)
|
||||
deletingIds.value = next
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => filters.value.customerId, (customerId) => {
|
||||
selectedCustomerId.value = String(customerId || '')
|
||||
page.value = 1
|
||||
void loadWarehouses(selectedCustomerId.value)
|
||||
})
|
||||
watch(() => filters.value.warehouseId, (warehouseId) => {
|
||||
selectedWarehouseId.value = String(warehouseId || '')
|
||||
page.value = 1
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = null
|
||||
})
|
||||
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||
if (nextPage === previousPage && nextSize === previousSize) return
|
||||
void loadPallets()
|
||||
})
|
||||
watch(() => access.can(editorPermission.value), (canWrite) => {
|
||||
if (!canWrite && editorOpen.value) editorOpen.value = false
|
||||
}, { flush: 'sync' })
|
||||
onMounted(() => {
|
||||
void loadCustomers()
|
||||
void loadPallets()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pallet-page">
|
||||
<LoadErrorState
|
||||
v-if="customerError"
|
||||
title="所属企业列表暂时无法加载"
|
||||
:message="customerError.message"
|
||||
:loading="customerLoading"
|
||||
@retry="loadCustomers"
|
||||
/>
|
||||
|
||||
<QueryToolbar
|
||||
v-model="filters"
|
||||
class="surface-panel pallet-filter-toolbar"
|
||||
ariaLabel="托盘筛选"
|
||||
:fields="queryFields"
|
||||
:search-on-reset="false"
|
||||
@search="applyFilters"
|
||||
@reset="resetFilters"
|
||||
/>
|
||||
|
||||
<LoadErrorState
|
||||
v-if="warehouseError"
|
||||
title="仓库列表暂时无法加载"
|
||||
:message="warehouseError.message"
|
||||
:loading="warehouseLoading"
|
||||
@retry="loadWarehouses()"
|
||||
/>
|
||||
|
||||
<LoadErrorState
|
||||
v-if="loadError"
|
||||
title="托盘列表暂时无法加载"
|
||||
:message="loadError.message"
|
||||
:loading="loading"
|
||||
@retry="loadPallets"
|
||||
/>
|
||||
|
||||
<section class="table-panel surface-panel" aria-label="托盘列表区域">
|
||||
<div class="panel-heading">
|
||||
<div><h2>托盘列表</h2><p>{{ currentCustomer?.tenantName || '全部客户' }} · {{ currentWarehouse?.warehouseName || '全部仓库' }} · 共 {{ total }} 个托盘</p></div>
|
||||
<el-button v-if="access.can('platform:pallet:create')" type="primary" :disabled="!contextReady" @click="openCreate"><Plus :size="16" />新增托盘</el-button>
|
||||
</div>
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:loading="loading || warehouseLoading"
|
||||
:total="total"
|
||||
row-key="id"
|
||||
:empty-text="filtered ? '暂无符合筛选条件的托盘' : selectedCustomerId ? '当前客户暂无托盘' : '暂无托盘数据'"
|
||||
aria-label="托盘列表"
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions pallet-row-actions">
|
||||
<el-button link type="primary" title="查看托盘" @click="openDetail(row as PlatformPalletResource)"><Eye :size="15" />查看</el-button>
|
||||
<el-button v-if="access.can('platform:pallet:edit')" link type="primary" title="编辑托盘" @click="openEdit(row as PlatformPalletResource)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button
|
||||
v-if="access.can('platform:pallet:delete')"
|
||||
link
|
||||
type="danger"
|
||||
title="删除托盘"
|
||||
:loading="deletingIds.has(String((row as PlatformPalletResource).id))"
|
||||
@click="removePallet(row as PlatformPalletResource)"
|
||||
><Trash2 :size="15" />删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #pallet="{ row }">
|
||||
<div class="pallet-cell"><span><Boxes :size="18" /></span><strong>{{ (row as PlatformPalletResource).palletCode }}</strong></div>
|
||||
</template>
|
||||
<template #capacity="{ row }"><strong>{{ formatCapacity((row as PlatformPalletResource).capacityCbm) }}</strong></template>
|
||||
<template #warehouse="{ row }"><span>{{ warehouseFor((row as PlatformPalletResource).warehouseId)?.warehouseName || '—' }}</span></template>
|
||||
<template #remark="{ row }"><span class="remark-text">{{ (row as PlatformPalletResource).remark || '—' }}</span></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="托盘详情" size="520px" class="business-drawer">
|
||||
<div v-loading="detailLoading" class="detail-body">
|
||||
<template v-if="detail">
|
||||
<div class="detail-hero"><span><Boxes :size="22" /></span><div><h2>{{ detail.palletCode }}</h2><p>{{ detail.palletType }}</p></div></div>
|
||||
<h3 class="detail-section-title">归属信息</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="所属企业">{{ customerLabel(detail.tenantId) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属仓库">{{ warehouseFor(detail.warehouseId)?.warehouseName || '—' }}({{ warehouseFor(detail.warehouseId)?.warehouseCode || '—' }})</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="detail-section-title">托盘规格</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="托盘编码">{{ detail.palletCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="托盘类型">{{ detail.palletType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="托盘容积">{{ formatCapacity(detail.capacityCbm) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="detail-section-title">记录信息</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.updatedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="editorTitle" size="520px" class="business-drawer" :close-on-click-modal="false">
|
||||
<div class="editor-context">
|
||||
<WarehouseIcon :size="18" />
|
||||
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}({{ editorWarehouse?.warehouseCode || form.warehouseId }})</strong><span>所属企业 {{ customerLabel(form.customerId) }} · 托盘保存后不可跨仓移动</span></div>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form">
|
||||
<el-form-item label="托盘编码" prop="palletCode" required>
|
||||
<el-input v-model="form.palletCode" maxlength="50" show-word-limit placeholder="例如 TP-001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="托盘类型" prop="palletType" required>
|
||||
<el-select
|
||||
v-model="form.palletType"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="请选择或输入托盘类型"
|
||||
aria-label="编辑托盘类型"
|
||||
>
|
||||
<el-option v-for="type in palletTypeOptions" :key="type" :label="type" :value="type" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="托盘容积(m³)" prop="capacityCbm" required>
|
||||
<el-input-number v-model="form.capacityCbm" :min="0.000001" :max="999999999999.999999" :precision="6" :step="0.1" controls-position="right" aria-label="标准容积" />
|
||||
<p class="field-help">用于服务端按总体积计算所需托盘数量;请输入托盘实际可用容积。</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写规格、材质或现场识别说明" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePallet">保存托盘</el-button></div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pallet-page { display: grid; min-width: 0; gap: 16px; }
|
||||
.surface-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.table-panel { min-width: 0; }
|
||||
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.panel-heading h2 { margin: 0; font-size: 16px; }
|
||||
.panel-heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.context-empty { padding: 56px 20px; color: var(--cc-muted); text-align: center; }
|
||||
.pallet-row-actions { gap: 8px; white-space: nowrap; }
|
||||
.pallet-row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.pallet-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.pallet-cell > span, .detail-hero > span { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
|
||||
.pallet-cell > span { width: 34px; height: 34px; }
|
||||
.remark-text { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-height: 1.55; }
|
||||
.detail-body { min-height: 180px; }
|
||||
.detail-hero { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||
.detail-hero > span { width: 46px; height: 46px; }
|
||||
.detail-hero h2, .detail-hero p { margin: 0; }
|
||||
.detail-hero h2 { font-size: 18px; }
|
||||
.detail-hero p { margin-top: 4px; color: var(--cc-muted); font-size: 13px; }
|
||||
.detail-section-title { margin: 20px 0 8px; color: var(--cc-text); font-size: 14px; }
|
||||
.detail-section-title:first-of-type { margin-top: 0; }
|
||||
.editor-context { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; padding: 12px; border: 1px solid var(--el-color-primary-light-7); border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
|
||||
.editor-context div { display: grid; gap: 2px; }
|
||||
.editor-context span { color: var(--cc-muted); font-size: 12px; }
|
||||
.editor-form :deep(.el-input-number), .editor-form :deep(.el-select) { width: 100%; }
|
||||
.field-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.drawer-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.drawer-footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.panel-heading { align-items: stretch; flex-direction: column; }
|
||||
.panel-heading > .el-button { width: 100%; min-height: 44px; }
|
||||
.pallet-row-actions :deep(.el-button) { min-height: 36px; padding-inline: 5px; }
|
||||
.drawer-footer { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.drawer-footer :deep(.el-button) { min-height: 44px; margin-left: 0; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.pallet-filter-toolbar :deep(.query-toolbar__filters) { width: 100%; flex-direction: column; }
|
||||
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-input),
|
||||
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-select),
|
||||
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-date-editor) { width: 100% !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const warehouseResourceRoutes = [
|
||||
{
|
||||
path: 'master-data/locations',
|
||||
name: 'locations',
|
||||
component: () => import('./locations/pages/LocationsView.vue'),
|
||||
meta: {
|
||||
title: '库位管理',
|
||||
description: '维护平台仓库库位和可用状态',
|
||||
permission: 'platform:location:list',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'master-data/pallets',
|
||||
name: 'pallets',
|
||||
component: () => import('./pallets/pages/PalletsView.vue'),
|
||||
meta: {
|
||||
title: '托盘管理',
|
||||
description: '维护平台仓库托盘和可用容积',
|
||||
permission: 'platform:pallet:list',
|
||||
},
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
Reference in New Issue
Block a user