This commit is contained in:
2026-08-04 21:01:39 +08:00
parent 88bd939170
commit 5cb1ca307d
77 changed files with 9914 additions and 131 deletions
@@ -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>