This commit is contained in:
2026-08-06 13:56:04 +08:00
parent 6736893128
commit a813426588
35 changed files with 2822 additions and 489 deletions
@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
changePlatformWarehouseStatus,
createFbaWarehouse,
createPlatformWarehouse,
getPlatformWarehouse,
importWarehouses,
listPlatformWarehouses,
updatePlatformWarehouse,
type SavePlatformWarehouseRequest,
@@ -15,12 +17,11 @@ vi.mock('@/shared/api/http', () => ({
}))
const payload: SavePlatformWarehouseRequest = {
tenantId: 10001,
warehouseCode: 'CN-YW-01',
warehouseName: '义乌收货仓',
warehouseType: 'DOMESTIC',
countryCode: 'CN',
timezone: 'Asia/Shanghai',
timezone: 'UTC+8',
contactName: '仓库客服',
contactPhone: '0579-12345678',
contactEmail: null,
@@ -55,20 +56,45 @@ describe('platform warehouse API', () => {
vi.mocked(http.patch).mockReset()
})
it('lists and loads warehouse details for the target tenant', async () => {
const page = { records: [], page: 1, size: 200, total: 0, pages: 0 }
it('lists server-paged platform warehouses and normalizes summary fields', async () => {
const page = {
records: [{
id: '1',
tenantId: 0,
warehouseCode: 'LAX9',
warehouseName: 'LAX9',
warehouseType: 'FBA',
countryCode: 'US',
timezone: 'UTC-8',
contactName: null,
contactPhone: null,
contactEmail: null,
status: 'ACTIVE',
remark: null,
}],
page: '1',
size: '20',
total: '1',
pages: '1',
}
const detail = { warehouse: {}, addresses: [], capabilities: [] }
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', message: 'success', data: page })
.mockResolvedValueOnce({ code: '200', message: 'success', data: detail })
await expect(listPlatformWarehouses(10001)).resolves.toEqual(page)
await expect(getPlatformWarehouse(10001, '19001')).resolves.toEqual(detail)
await expect(listPlatformWarehouses({ page: 1, size: 20, keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' })).resolves.toEqual({
records: [{ ...page.records[0], primaryAddress: null, addressSummary: null }],
page: 1,
size: 20,
total: 1,
pages: 1,
})
await expect(getPlatformWarehouse('19001')).resolves.toEqual(detail)
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin', {
params: { tenantId: 10001, page: 1, size: 200 },
params: { page: 1, size: 20, keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' },
})
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/19001', {
params: { tenantId: 10001 },
params: { tenantId: 0 },
})
})
@@ -89,15 +115,87 @@ describe('platform warehouse API', () => {
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
await expect(createPlatformWarehouse(payload)).resolves.toBe('19001')
await expect(updatePlatformWarehouse(10001, '19001', payload)).resolves.toBeNull()
await expect(changePlatformWarehouseStatus(10001, '19001', 'INACTIVE')).resolves.toBeNull()
await expect(updatePlatformWarehouse('19001', payload)).resolves.toBeNull()
await expect(changePlatformWarehouseStatus('19001', 'INACTIVE')).resolves.toBeNull()
expect(http.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin', payload)
expect(http.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/19001', payload, {
params: { tenantId: 10001 },
expect(http.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin', { ...payload, tenantId: 0 })
expect(http.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/19001', { ...payload, tenantId: 0 }, {
params: { tenantId: 0 },
})
expect(http.patch).toHaveBeenCalledWith('/platform/v1/warehouse-admin/19001/status', undefined, {
params: { tenantId: 10001, status: 'INACTIVE' },
params: { tenantId: 0, status: 'INACTIVE' },
})
})
it('uses the FBA fast-create defaults and implemented import routes without client-side success simulation', async () => {
vi.mocked(http.post)
.mockResolvedValueOnce({ code: '200', message: 'success', data: '19002' })
.mockResolvedValueOnce({
code: '200',
message: 'success',
data: {
fileName: 'fba.xlsx',
totalRows: '3',
createdRows: '1',
updatedRows: '1',
unchangedRows: '0',
success: false,
committed: false,
errors: [{ row: '3', field: '仓库编码', message: '编码重复' }],
},
})
await expect(createFbaWarehouse({
warehouseCode: 'LAX9',
stateProvince: 'CA',
city: 'Long Beach',
postalCode: '90802',
addressLine1: '2400 E Spring St',
})).resolves.toBe('19002')
const file = new File(['content'], 'warehouses.xlsx')
await expect(importWarehouses(file, true, 'CREATE_ONLY')).resolves.toMatchObject({
totalRows: 3,
validRows: 2,
createCount: 1,
updateCount: 1,
unchangedCount: 0,
errorCount: 1,
committed: false,
errors: [{ row: 3, field: '仓库编码', message: '编码重复' }],
})
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin', {
warehouseCode: 'LAX9',
warehouseName: 'LAX9',
warehouseType: 'FBA',
countryCode: 'US',
timezone: '',
contactName: null,
contactPhone: null,
contactEmail: null,
status: 'ACTIVE',
remark: null,
address: {
addressType: 'OPERATING',
countryCode: 'US',
stateProvince: 'CA',
city: 'Long Beach',
district: null,
postalCode: '90802',
addressLine1: '2400 E Spring St',
addressLine2: null,
latitude: null,
longitude: null,
},
capabilities: [],
tenantId: 0,
})
expect(http.post).toHaveBeenNthCalledWith(
2,
'/platform/v1/warehouse-admin/import',
expect.any(FormData),
{ params: { tenantId: 0, dryRun: true, mode: 'CREATE_ONLY' } },
)
})
})
+172 -17
View File
@@ -54,14 +54,34 @@ export interface PlatformWarehouseDetailResource {
capabilities: PlatformWarehouseCapabilityResource[]
}
export interface PlatformWarehouseSummaryResource extends PlatformWarehouseResource {
primaryAddress?: PlatformWarehouseAddressResource | null
addressSummary?: string | null
}
export interface PlatformWarehousePage {
records: PlatformWarehouseResource[]
records: PlatformWarehouseSummaryResource[]
page: number
size: number
total: number
pages: number
}
type PlatformWarehousePagePayload = Omit<PlatformWarehousePage, 'page' | 'size' | 'total' | 'pages'> & {
page: number | string
size: number | string
total: number | string
pages: number | string
}
export interface PlatformWarehouseQuery {
page: number
size: number
keyword?: string
warehouseType?: WarehouseType
status?: PlatformWarehouseStatus
}
export interface PlatformWarehouseAddressRequest {
addressType: string
countryCode: string
@@ -84,7 +104,6 @@ export interface PlatformWarehouseCapabilityRequest {
}
export interface SavePlatformWarehouseRequest {
tenantId: number | string
warehouseCode: string
warehouseName: string
warehouseType: WarehouseType
@@ -99,42 +118,178 @@ export interface SavePlatformWarehouseRequest {
capabilities: PlatformWarehouseCapabilityRequest[]
}
const basePath = '/platform/v1/warehouse-admin'
export async function listPlatformWarehouses(tenantId?: number | string, page = 1, size = 200) {
const response = await http.get<ApiEnvelope<PlatformWarehousePage>, ApiEnvelope<PlatformWarehousePage>>(basePath, {
params: { ...(tenantId === undefined || tenantId === '' ? {} : { tenantId }), page, size },
})
return unwrapApiResponse(response)
export interface CreateFbaWarehouseRequest {
warehouseCode: string
stateProvince: string
city: string
postalCode: string
addressLine1: string
timezone?: string | null
}
export async function getPlatformWarehouse(tenantId: number | string, warehouseId: number | string) {
export type WarehouseImportMode = 'CREATE_ONLY' | 'UPSERT'
export interface WarehouseImportError {
row: number
field: string
code?: string | null
message: string
}
export interface WarehouseImportResult {
fileName: string
totalRows: number
validRows: number
createCount: number
updateCount: number
unchangedCount: number
errorCount: number
committed: boolean
errors: WarehouseImportError[]
}
const basePath = '/platform/v1/warehouse-admin'
const platformResourceScope = 0
type PlatformWarehouseApiSaveRequest = SavePlatformWarehouseRequest & { tenantId: number }
type WarehouseImportErrorPayload = Omit<WarehouseImportError, 'row'> & { row: number | string }
type WarehouseImportResultPayload = Partial<Omit<
WarehouseImportResult,
'totalRows' | 'validRows' | 'createCount' | 'updateCount' | 'unchangedCount' | 'errorCount' | 'committed' | 'errors'
>> & {
totalRows: number | string
validRows?: number | string
createCount?: number | string
createdRows?: number | string
updateCount?: number | string
updatedRows?: number | string
unchangedCount?: number | string
unchangedRows?: number | string
errorCount?: number | string
success?: boolean
committed?: boolean
errors?: WarehouseImportErrorPayload[] | null
}
function withPlatformScope(payload: SavePlatformWarehouseRequest): PlatformWarehouseApiSaveRequest {
return { ...payload, tenantId: platformResourceScope }
}
function toNumber(value: number | string | undefined) {
return Number(value ?? 0)
}
function normalizeWarehouseImportResult(result: WarehouseImportResultPayload, dryRun: boolean): WarehouseImportResult {
const errors = (result.errors || []).map((error) => ({ ...error, row: toNumber(error.row) }))
const errorCount = toNumber(result.errorCount) || errors.length
const totalRows = toNumber(result.totalRows)
return {
...result,
fileName: result.fileName || '',
totalRows,
validRows: toNumber(result.validRows) || Math.max(totalRows - errorCount, 0),
createCount: toNumber(result.createCount ?? result.createdRows),
updateCount: toNumber(result.updateCount ?? result.updatedRows),
unchangedCount: toNumber(result.unchangedCount ?? result.unchangedRows),
errorCount,
committed: result.committed ?? (!dryRun && Boolean(result.success) && errorCount === 0),
errors,
}
}
export async function listPlatformWarehouses(query?: PlatformWarehouseQuery): Promise<PlatformWarehousePage>
export async function listPlatformWarehouses(page?: number, size?: number): Promise<PlatformWarehousePage>
export async function listPlatformWarehouses(pageOrQuery: number | PlatformWarehouseQuery = 1, size = 200) {
const query = typeof pageOrQuery === 'number'
? { page: pageOrQuery, size }
: pageOrQuery
const response = await http.get<ApiEnvelope<PlatformWarehousePagePayload>, ApiEnvelope<PlatformWarehousePagePayload>>(basePath, {
params: query,
})
const result = unwrapApiResponse(response)
return {
...result,
records: result.records.map((warehouse) => ({
...warehouse,
primaryAddress: warehouse.primaryAddress ?? null,
addressSummary: warehouse.addressSummary ?? null,
})),
page: Number(result.page),
size: Number(result.size),
total: Number(result.total),
pages: Number(result.pages),
}
}
export async function getPlatformWarehouse(warehouseId: number | string) {
const response = await http.get<ApiEnvelope<PlatformWarehouseDetailResource>, ApiEnvelope<PlatformWarehouseDetailResource>>(
`${basePath}/${warehouseId}`,
{ params: { tenantId } },
{ params: { tenantId: platformResourceScope } },
)
return unwrapApiResponse(response)
}
export async function createPlatformWarehouse(payload: SavePlatformWarehouseRequest) {
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload)
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, withPlatformScope(payload))
return unwrapApiResponse(response)
}
export async function updatePlatformWarehouse(tenantId: number | string, warehouseId: number | string, payload: SavePlatformWarehouseRequest) {
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${warehouseId}`, payload, {
params: { tenantId },
export async function createFbaWarehouse(payload: CreateFbaWarehouseRequest) {
return createPlatformWarehouse({
warehouseCode: payload.warehouseCode,
warehouseName: payload.warehouseCode,
warehouseType: 'FBA',
countryCode: 'US',
timezone: payload.timezone?.trim() || '',
contactName: null,
contactPhone: null,
contactEmail: null,
status: 'ACTIVE',
remark: null,
address: {
addressType: 'OPERATING',
countryCode: 'US',
stateProvince: payload.stateProvince,
city: payload.city,
district: null,
postalCode: payload.postalCode,
addressLine1: payload.addressLine1,
addressLine2: null,
latitude: null,
longitude: null,
},
capabilities: [],
})
}
export async function downloadWarehouseImportTemplate() {
return http.get<Blob, Blob>(`${basePath}/import-template`, { responseType: 'blob' })
}
export async function importWarehouses(file: File, dryRun: boolean, mode: WarehouseImportMode) {
const form = new FormData()
form.append('file', file)
const response = await http.post<ApiEnvelope<WarehouseImportResultPayload>, ApiEnvelope<WarehouseImportResultPayload>>(
`${basePath}/import`,
form,
{ params: { tenantId: platformResourceScope, dryRun, mode } },
)
return normalizeWarehouseImportResult(unwrapApiResponse(response), dryRun)
}
export async function updatePlatformWarehouse(warehouseId: number | string, payload: SavePlatformWarehouseRequest) {
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${warehouseId}`, withPlatformScope(payload), {
params: { tenantId: platformResourceScope },
})
return unwrapApiResponse(response)
}
export async function changePlatformWarehouseStatus(
tenantId: number | string,
warehouseId: number | string,
status: PlatformWarehouseStatus,
) {
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${warehouseId}/status`, undefined, {
params: { tenantId, status },
params: { tenantId: platformResourceScope, status },
})
return unwrapApiResponse(response)
}
@@ -2,25 +2,32 @@ import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DataTable from '@/shared/components/DataTable.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import {
createFbaWarehouse,
createPlatformWarehouse,
getPlatformWarehouse,
importWarehouses,
listPlatformWarehouses,
updatePlatformWarehouse,
type PlatformWarehouseDetailResource,
type PlatformWarehouseResource,
type PlatformWarehouseSummaryResource,
type WarehouseImportResult,
} from '@/modules/warehouses/api/platformWarehouses'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import WarehousesView from './WarehousesView.vue'
vi.mock('@/modules/warehouses/api/platformWarehouses', () => ({
listPlatformWarehouses: vi.fn(),
getPlatformWarehouse: vi.fn(),
createPlatformWarehouse: vi.fn(),
createFbaWarehouse: vi.fn(),
updatePlatformWarehouse: vi.fn(),
changePlatformWarehouseStatus: vi.fn(),
downloadWarehouseImportTemplate: vi.fn(),
importWarehouses: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
@@ -41,43 +48,109 @@ function createMemoryStorage(initial: Record<string, string> = {}): Storage {
}
}
const warehouse: PlatformWarehouseResource = {
const domesticAddress = {
id: '19002',
tenantId: 0,
warehouseId: '19001',
addressType: 'OPERATING',
countryCode: 'CN',
stateProvince: '浙江省',
city: '义乌市',
district: '北苑街道',
postalCode: '322000',
addressLine1: '真实物流园 1 号库',
addressLine2: 'A 栋',
latitude: '29.3',
longitude: '120.0',
}
const domesticWarehouse: PlatformWarehouseSummaryResource = {
id: '19001',
tenantId: 10001,
warehouseCode: 'CN-TEST-01',
warehouseName: '测试环境收货仓',
tenantId: 0,
warehouseCode: 'CN-YW-01',
warehouseName: '义乌收货仓',
warehouseType: 'DOMESTIC',
countryCode: 'CN',
timezone: 'Asia/Shanghai',
contactName: '真实联系人',
timezone: 'UTC+8',
contactName: '仓库客服',
contactPhone: '0579-12345678',
contactEmail: 'warehouse@example.com',
status: 'ACTIVE',
remark: '测试环境数据',
updatedAt: '2026-07-31T10:20:00',
addressSummary: '浙江省义乌市北苑街道 1 号库',
primaryAddress: domesticAddress,
}
const fbaAddress = {
id: '19012',
tenantId: 0,
warehouseId: '19011',
addressType: 'OPERATING',
countryCode: 'US',
stateProvince: 'OH',
city: 'Canton',
district: null,
postalCode: '44705',
addressLine1: '4747 Rebar Ave NE',
addressLine2: null,
latitude: null,
longitude: null,
}
const fbaWarehouse: PlatformWarehouseSummaryResource = {
id: '19011',
tenantId: 0,
warehouseCode: 'AKR1',
warehouseName: 'AKR1',
warehouseType: 'FBA',
countryCode: 'US',
timezone: 'UTC-5',
contactName: null,
contactPhone: null,
contactEmail: null,
status: 'ACTIVE',
remark: null,
updatedAt: '2026-07-31T10:20:00',
addressSummary: 'OH · Canton',
primaryAddress: fbaAddress,
}
const fbaDetail: PlatformWarehouseDetailResource = {
warehouse: fbaWarehouse,
addresses: [fbaAddress],
capabilities: [],
}
const detail: PlatformWarehouseDetailResource = {
warehouse,
addresses: [{
id: '19002', tenantId: 10001, warehouseId: '19001', addressType: 'OPERATING', countryCode: 'CN',
stateProvince: '浙江省', city: '义乌市', district: '北苑街道', postalCode: '322000',
addressLine1: '真实物流园 1 号库', addressLine2: 'A 栋', latitude: '29.3', longitude: '120.0',
}],
warehouse: domesticWarehouse,
addresses: [domesticAddress],
capabilities: [
{ id: '19003', tenantId: 10001, warehouseId: '19001', capabilityCode: 'RECEIVING', capabilityName: '收货服务', enabled: true, cutoffTime: '17:00:00', description: '周一至周六 08:30-18:00' },
{ id: '19004', tenantId: 10001, warehouseId: '19001', capabilityCode: 'STORAGE', capabilityName: '仓储', enabled: true, cutoffTime: null, description: '托盘仓储' },
{ id: '19003', tenantId: 0, warehouseId: '19001', capabilityCode: 'RECEIVING', capabilityName: '收货服务', enabled: true, cutoffTime: '17:00:00', description: '周一至周六 08:30-18:00' },
{ id: '19004', tenantId: 0, warehouseId: '19001', capabilityCode: 'STORAGE', capabilityName: '仓储', enabled: true, cutoffTime: null, description: '托盘仓储' },
],
}
async function mountView() {
const importPreview: WarehouseImportResult = {
fileName: 'warehouses.xlsx',
totalRows: 4,
validRows: 3,
createCount: 2,
updateCount: 1,
unchangedCount: 0,
errorCount: 1,
committed: false,
errors: [{ row: 4, field: '仓库编码', code: 'DUPLICATE_CODE', message: '文件内编码重复' }],
}
async function mountView(permissions = ['platform:warehouse:list', 'platform:warehouse:create', 'platform:warehouse:edit', 'platform:warehouse:status']) {
vi.stubGlobal('localStorage', createMemoryStorage())
vi.stubGlobal('sessionStorage', createMemoryStorage())
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['platform:warehouse:list', 'platform:warehouse:create', 'platform:warehouse:edit', 'platform:warehouse:status']
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(WarehousesView, {
attachTo: document.body,
@@ -87,72 +160,80 @@ async function mountView() {
return wrapper
}
function setField(selector: string, value: string) {
const field = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(selector)
expect(field).not.toBeNull()
field!.value = value
field!.dispatchEvent(new Event('input', { bubbles: true }))
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('WarehousesView real API integration', () => {
describe('WarehousesView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
document.body.innerHTML = ''
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [domesticWarehouse], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformWarehouse).mockReset().mockResolvedValue(detail)
vi.mocked(createPlatformWarehouse).mockReset().mockResolvedValue('19005')
vi.mocked(createFbaWarehouse).mockReset().mockResolvedValue('19006')
vi.mocked(updatePlatformWarehouse).mockReset().mockResolvedValue(null)
vi.mocked(importWarehouses).mockReset().mockResolvedValue(importPreview)
})
it('automatically loads the fixed tenant and renders address and receiving capability from details', async () => {
it('loads a server-paged warehouse list with backend address summaries and no row detail fan-out', async () => {
const wrapper = await mountView()
expect(listPlatformWarehouses).toHaveBeenCalledWith(10001, 1, 200)
expect(getPlatformWarehouse).toHaveBeenCalledWith(10001, '19001')
expect(wrapper.text()).toContain('测试环境收货仓')
expect(wrapper.text()).toContain('北苑街道 真实物流园 1 号库 A 栋')
expect(wrapper.text()).toContain('周一至周六 08:30-18:00')
expect(listPlatformWarehouses).toHaveBeenCalledWith({
page: 1,
size: 20,
keyword: undefined,
warehouseType: undefined,
status: undefined,
})
expect(getPlatformWarehouse).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('义乌收货仓')
expect(wrapper.text()).toContain('浙江省义乌市北苑街道 1 号库')
expect(wrapper.text()).toContain('国内收货仓')
expect(wrapper.text()).toContain('Asia/Shanghai')
expect(wrapper.text()).not.toContain('全部收货仓')
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
expect(wrapper.text()).toContain('中国 CN')
expect(wrapper.text()).toContain('UTC+8')
expect(wrapper.text()).toContain('共 1 个公共仓库')
expect(wrapper.find('.data-table').exists()).toBe(true)
})
it('updates filters locally and only reloads after search', async () => {
const wrapper = await mountView()
const keywordInput = wrapper.find('input[placeholder="仓库名称、编码、城市或地址"]')
await keywordInput.setValue('义乌')
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenCalledTimes(1)
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === '查询')
expect(searchButton).toBeDefined()
await searchButton?.trigger('click')
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('测试环境收货仓')
})
it('uses the fixed public tenant without rendering a tenant selector', async () => {
const wrapper = await mountView()
expect(wrapper.find('[aria-label="目标企业"]').exists()).toBe(false)
expect(listPlatformWarehouses).toHaveBeenCalledWith(10001, 1, 200)
expect(wrapper.text()).toContain('公共仓库')
})
it('filters the public warehouse table by warehouse type', async () => {
it('submits filters and pagination to the API instead of filtering rows locally', async () => {
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [domesticWarehouse], page: 1, size: 20, total: 45, pages: 3 })
const wrapper = await mountView()
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
keyword: '', warehouseType: 'FBA', status: '',
keyword: 'FBA',
warehouseType: 'FBA',
status: 'ACTIVE',
})
await flushPromises()
expect(wrapper.text()).not.toContain('测试环境收货仓')
expect(wrapper.text()).toContain('暂无符合筛选条件的仓库')
expect(listPlatformWarehouses).toHaveBeenLastCalledWith({
page: 1,
size: 20,
keyword: 'FBA',
warehouseType: 'FBA',
status: 'ACTIVE',
})
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenLastCalledWith({
page: 2,
size: 20,
keyword: 'FBA',
warehouseType: 'FBA',
status: 'ACTIVE',
})
})
it('shows a recoverable real API error without falling back to demo warehouses', async () => {
it('shows a recoverable API error without falling back to demo warehouses', async () => {
vi.mocked(listPlatformWarehouses).mockRejectedValueOnce(new Error('Gateway unavailable'))
const wrapper = await mountView()
@@ -162,66 +243,219 @@ describe('WarehousesView real API integration', () => {
expect(wrapper.text()).not.toContain('演示物流园')
})
it('preserves backend-only address, email, capability and cutoff fields when updating', async () => {
it('keeps create actions behind permissions and preserves a read-only table state', async () => {
const wrapper = await mountView(['platform:warehouse:list'])
expect(wrapper.text()).not.toContain('新增仓库')
expect(wrapper.text()).not.toContain('导入仓库')
expect(wrapper.text()).toContain('仅查看')
})
it('creates an FBA address from the fast path with selectable state and timezone values', async () => {
const wrapper = await mountView()
const editButton = wrapper.findAll('button').find((button) => button.text().trim() === '编辑')
expect(editButton).toBeDefined()
await editButton?.trigger('click')
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'FBA')
await flushPromises()
const typeFormItem = wrapper.findAll('.el-form-item').find((item) => item.text().includes('仓库类型'))
expect(typeFormItem).toBeDefined()
typeFormItem?.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'FBA')
await wrapper.find('input[placeholder="例如 CN、US"]').setValue('US')
await wrapper.find('input[placeholder="例如 Asia/Shanghai"]').setValue('America/Los_Angeles')
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
expect(drawerText).toContain('新增FBA 地址')
expect(drawerText).toContain('国家或地区')
expect(drawerText).not.toContain('时区覆盖')
expect(drawerText).not.toContain('联系人')
expect(drawerText).not.toContain('收货时间')
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '美国 US')
expect(countrySelect).toBeDefined()
expect(countrySelect!.props('modelValue')).toBe('US')
expect(countrySelect!.props('disabled')).toBe(true)
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === '保存仓库')
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择州')
expect(stateSelect).toBeDefined()
setField('input[placeholder="例如 LAX9"]', 'lax9')
stateSelect!.vm.$emit('update:modelValue', 'CA')
stateSelect!.vm.$emit('change', 'CA')
await flushPromises()
setField('input[placeholder="例如 Long Beach"]', 'Long Beach')
setField('input[placeholder="例如 90802"]', '90802')
setField('textarea[placeholder="请输入街道和门牌信息"]', '2400 E Spring St')
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认新增')
expect(saveButton).toBeDefined()
await saveButton?.trigger('click')
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(createFbaWarehouse).toHaveBeenCalledWith({
warehouseCode: 'LAX9',
stateProvince: 'CA',
city: 'Long Beach',
postalCode: '90802',
addressLine1: '2400 E Spring St',
timezone: 'UTC-8',
})
expect(createPlatformWarehouse).not.toHaveBeenCalled()
})
it('hides domestic country, timezone and type inputs while submitting fixed CN and UTC+8 defaults', async () => {
const wrapper = await mountView()
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'DOMESTIC')
await flushPromises()
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
expect(drawerText).not.toContain('国家或地区')
expect(drawerText).not.toContain('时区')
expect(drawerText).not.toContain('仓库类型')
setField('input[placeholder="请输入仓库名称"]', '义乌新仓')
setField('input[placeholder="例如 浙江省"]', '浙江省')
setField('input[placeholder="例如 义乌市"]', '义乌市')
setField('textarea[placeholder="请输入街道、园区、楼栋及门牌信息"]', '稠州北路 100 号')
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认新增')
expect(saveButton).toBeDefined()
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(createPlatformWarehouse).toHaveBeenCalledOnce()
expect(vi.mocked(createPlatformWarehouse).mock.calls[0][0]).toMatchObject({
warehouseName: '义乌新仓',
warehouseType: 'DOMESTIC',
countryCode: 'CN',
timezone: 'UTC+8',
address: expect.objectContaining({ countryCode: 'CN', city: '义乌市' }),
})
})
it('uses country and state selectors for overseas warehouse creation and auto-fills timezone when safe', async () => {
const wrapper = await mountView()
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'OVERSEAS')
await flushPromises()
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择国家或地区')
expect(countrySelect).toBeDefined()
expect(countrySelect!.props('modelValue')).toBe('US')
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择州')
expect(stateSelect).toBeDefined()
const timezoneSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择时区')
expect(timezoneSelect).toBeDefined()
expect(wrapper.findAllComponents({ name: 'ElOption' }).some((option) => option.props('value') === 'UTC-8')).toBe(true)
setField('input[placeholder="请输入仓库名称"]', '洛杉矶海外仓')
stateSelect!.vm.$emit('update:modelValue', 'CA')
stateSelect!.vm.$emit('change', 'CA')
setField('input[placeholder="例如 Long Beach"]', 'Long Beach')
setField('textarea[placeholder="请输入街道、园区、楼栋及门牌信息"]', '2400 E Spring St')
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认新增')
expect(saveButton).toBeDefined()
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(createPlatformWarehouse).toHaveBeenCalledOnce()
expect(vi.mocked(createPlatformWarehouse).mock.calls[0][0]).toMatchObject({
warehouseName: '洛杉矶海外仓',
warehouseType: 'OVERSEAS',
countryCode: 'US',
timezone: 'UTC-8',
address: expect.objectContaining({ stateProvince: 'CA' }),
})
})
it('edits FBA warehouses with selectors for country, timezone and state', async () => {
vi.mocked(listPlatformWarehouses).mockResolvedValueOnce({ records: [fbaWarehouse, domesticWarehouse], page: 1, size: 20, total: 2, pages: 1 })
vi.mocked(getPlatformWarehouse).mockResolvedValueOnce(fbaDetail)
const wrapper = await mountView()
const editButton = wrapper.find('.el-table__body-wrapper .table-actions button')
expect(editButton).toBeDefined()
await editButton.trigger('click')
await flushPromises()
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
expect(drawerText).toContain('编辑FBA 地址')
expect(drawerText).not.toContain('例如 US')
expect(drawerText).not.toContain('例如 UTC-8')
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择国家或地区')
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择州')
const timezoneSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择时区')
expect(countrySelect?.props('modelValue')).toBe('US')
expect(stateSelect?.props('modelValue')).toBe('OH')
expect(timezoneSelect?.props('modelValue')).toBe('UTC-5')
expect(countrySelect).toBeDefined()
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '保存修改')
expect(saveButton).toBeDefined()
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(updatePlatformWarehouse).toHaveBeenCalledOnce()
const [, , payload] = vi.mocked(updatePlatformWarehouse).mock.calls[0]
expect(payload.contactEmail).toBe('warehouse@example.com')
const [, payload] = vi.mocked(updatePlatformWarehouse).mock.calls[0]
expect(payload).toMatchObject({
tenantId: 10001, warehouseType: 'FBA', countryCode: 'US', timezone: 'America/Los_Angeles',
})
expect(payload.address).toMatchObject({
countryCode: 'US', district: '北苑街道', postalCode: '322000', addressLine1: '真实物流园 1 号库', addressLine2: 'A 栋',
latitude: '29.3', longitude: '120.0',
warehouseType: 'FBA',
countryCode: 'US',
timezone: 'UTC-5',
address: expect.objectContaining({ stateProvince: 'OH' }),
})
})
it('edits existing records with full fields and preserves backend-only capabilities', async () => {
const wrapper = await mountView()
const editButton = wrapper.find('.el-table__body-wrapper .table-actions button')
expect(editButton).toBeDefined()
await editButton.trigger('click')
await flushPromises()
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '保存修改')
expect(saveButton).toBeDefined()
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(getPlatformWarehouse).toHaveBeenCalledWith('19001')
expect(updatePlatformWarehouse).toHaveBeenCalledOnce()
const [, payload] = vi.mocked(updatePlatformWarehouse).mock.calls[0]
expect(payload.contactEmail).toBe('warehouse@example.com')
expect(payload.capabilities).toContainEqual(expect.objectContaining({
capabilityCode: 'RECEIVING', cutoffTime: '17:00:00', description: '周一至周六 08:30-18:00',
capabilityCode: 'RECEIVING',
cutoffTime: '17:00:00',
description: '周一至周六 08:30-18:00',
}))
expect(payload.capabilities).toContainEqual(expect.objectContaining({ capabilityCode: 'STORAGE', description: '托盘仓储' }))
})
it('shows field-level required errors and does not submit an empty warehouse', async () => {
it('renders FBA import precheck totals and row-level errors without committing by default', async () => {
const wrapper = await mountView()
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增仓库')
expect(createButton).toBeDefined()
await createButton?.trigger('click')
await wrapper.findAll('button').find((button) => button.text().trim() === '导入仓库')?.trigger('click')
await flushPromises()
expect(document.querySelector('input[placeholder="例如 广东省"]')).not.toBeNull()
expect(document.querySelector('input[placeholder="例如 CN、US"]')).not.toBeNull()
expect(document.querySelector('input[placeholder="例如 Asia/Shanghai"]')).not.toBeNull()
expect(document.querySelector('input[placeholder="请输入仓库联系人姓名"]')).not.toBeNull()
expect(document.querySelector('textarea[placeholder="请输入街道、园区、楼栋及库位信息"]')).not.toBeNull()
await wrapper.find('input[placeholder="例如 CN-NB-01"]').trigger('blur')
await wrapper.vm.$nextTick()
expect(wrapper.find('#warehouse-code-error').text()).toBe('请输入仓库编码')
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === '保存仓库')
expect(saveButton).toBeDefined()
await saveButton?.trigger('click')
const upload = wrapper.findComponent({ name: 'ElUpload' })
const file = new File(['content'], 'fba.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
upload.props('onChange')({ name: file.name, raw: file })
await flushPromises()
expect(createPlatformWarehouse).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请输入仓库编码')
expect(wrapper.text()).toContain('请输入仓库名称')
expect(wrapper.text()).toContain('请输入城市')
expect(wrapper.text()).toContain('请输入详细地址')
const precheckButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '预检文件')
expect(precheckButton).toBeDefined()
precheckButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(importWarehouses).toHaveBeenCalledWith(file, true, 'CREATE_ONLY')
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(importWarehouses).toHaveBeenCalledTimes(1)
})
})
+531 -123
View File
@@ -1,43 +1,135 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MapPin, Pencil, Plus, Power, Warehouse as WarehouseIcon } from '@lucide/vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ChevronDown, Download, FileSpreadsheet, MapPin, Pencil, Plus, Power, Upload, Warehouse as WarehouseIcon } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
changePlatformWarehouseStatus,
createFbaWarehouse,
createPlatformWarehouse,
downloadWarehouseImportTemplate,
getPlatformWarehouse,
importWarehouses,
listPlatformWarehouses,
updatePlatformWarehouse,
type WarehouseImportMode,
type WarehouseImportResult,
type PlatformWarehouseDetailResource,
type PlatformWarehouseResource,
type PlatformWarehouseSummaryResource,
type SavePlatformWarehouseRequest,
type WarehouseType,
} from '@/modules/warehouses/api/platformWarehouses'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import type { Warehouse } from '@/modules/warehouses/types'
const DEFAULT_TENANT_ID = 10001
const warehouseTypeLabels: Record<WarehouseType, string> = {
DOMESTIC: '国内收货仓',
FBA: 'FBA 仓',
FBA: 'FBA 地址',
OVERSEAS: '海外仓',
}
const timezoneOptions = [
'UTC-12', 'UTC-11', 'UTC-10', 'UTC-9', 'UTC-8', 'UTC-7', 'UTC-6', 'UTC-5', 'UTC-4', 'UTC-3',
'UTC-2', 'UTC-1', 'UTC+0', 'UTC+1', 'UTC+2', 'UTC+3', 'UTC+4', 'UTC+5', 'UTC+6', 'UTC+7',
'UTC+8', 'UTC+9', 'UTC+10', 'UTC+11', 'UTC+12', 'UTC+13', 'UTC+14',
]
const countryOptions = [
{ label: '美国 US', value: 'US' },
{ label: '加拿大 CA', value: 'CA' },
{ label: '英国 GB', value: 'GB' },
{ label: '德国 DE', value: 'DE' },
{ label: '法国 FR', value: 'FR' },
{ label: '意大利 IT', value: 'IT' },
{ label: '西班牙 ES', value: 'ES' },
{ label: '日本 JP', value: 'JP' },
{ label: '澳大利亚 AU', value: 'AU' },
]
const countryDisplayLabels: Record<string, string> = {
CN: '中国',
US: '美国',
CA: '加拿大',
GB: '英国',
DE: '德国',
FR: '法国',
IT: '意大利',
ES: '西班牙',
JP: '日本',
AU: '澳大利亚',
}
const usStateOptions = [
{ label: 'AL Alabama', value: 'AL', timezone: 'UTC-6' },
{ label: 'AK Alaska', value: 'AK', timezone: 'UTC-9' },
{ label: 'AZ Arizona', value: 'AZ', timezone: 'UTC-7' },
{ label: 'AR Arkansas', value: 'AR', timezone: 'UTC-6' },
{ label: 'CA California', value: 'CA', timezone: 'UTC-8' },
{ label: 'CO Colorado', value: 'CO', timezone: 'UTC-7' },
{ label: 'CT Connecticut', value: 'CT', timezone: 'UTC-5' },
{ label: 'DE Delaware', value: 'DE', timezone: 'UTC-5' },
{ label: 'FL Florida', value: 'FL', timezone: null },
{ label: 'GA Georgia', value: 'GA', timezone: 'UTC-5' },
{ label: 'HI Hawaii', value: 'HI', timezone: 'UTC-10' },
{ label: 'IA Iowa', value: 'IA', timezone: 'UTC-6' },
{ label: 'ID Idaho', value: 'ID', timezone: null },
{ label: 'IL Illinois', value: 'IL', timezone: 'UTC-6' },
{ label: 'IN Indiana', value: 'IN', timezone: null },
{ label: 'KS Kansas', value: 'KS', timezone: null },
{ label: 'KY Kentucky', value: 'KY', timezone: null },
{ label: 'LA Louisiana', value: 'LA', timezone: 'UTC-6' },
{ label: 'MA Massachusetts', value: 'MA', timezone: 'UTC-5' },
{ label: 'MD Maryland', value: 'MD', timezone: 'UTC-5' },
{ label: 'ME Maine', value: 'ME', timezone: 'UTC-5' },
{ label: 'MI Michigan', value: 'MI', timezone: null },
{ label: 'MN Minnesota', value: 'MN', timezone: 'UTC-6' },
{ label: 'MO Missouri', value: 'MO', timezone: 'UTC-6' },
{ label: 'MS Mississippi', value: 'MS', timezone: 'UTC-6' },
{ label: 'MT Montana', value: 'MT', timezone: 'UTC-7' },
{ label: 'NC North Carolina', value: 'NC', timezone: 'UTC-5' },
{ label: 'ND North Dakota', value: 'ND', timezone: null },
{ label: 'NE Nebraska', value: 'NE', timezone: null },
{ label: 'NH New Hampshire', value: 'NH', timezone: 'UTC-5' },
{ label: 'NJ New Jersey', value: 'NJ', timezone: 'UTC-5' },
{ label: 'NM New Mexico', value: 'NM', timezone: 'UTC-7' },
{ label: 'NV Nevada', value: 'NV', timezone: 'UTC-8' },
{ label: 'NY New York', value: 'NY', timezone: 'UTC-5' },
{ label: 'OH Ohio', value: 'OH', timezone: 'UTC-5' },
{ label: 'OK Oklahoma', value: 'OK', timezone: 'UTC-6' },
{ label: 'OR Oregon', value: 'OR', timezone: null },
{ label: 'PA Pennsylvania', value: 'PA', timezone: 'UTC-5' },
{ label: 'RI Rhode Island', value: 'RI', timezone: 'UTC-5' },
{ label: 'SC South Carolina', value: 'SC', timezone: 'UTC-5' },
{ label: 'SD South Dakota', value: 'SD', timezone: null },
{ label: 'TN Tennessee', value: 'TN', timezone: null },
{ label: 'TX Texas', value: 'TX', timezone: null },
{ label: 'VA Virginia', value: 'VA', timezone: 'UTC-5' },
{ label: 'VT Vermont', value: 'VT', timezone: 'UTC-5' },
{ label: 'WA Washington', value: 'WA', timezone: 'UTC-8' },
{ label: 'WI Wisconsin', value: 'WI', timezone: 'UTC-6' },
{ label: 'WV West Virginia', value: 'WV', timezone: 'UTC-5' },
{ label: 'WY Wyoming', value: 'WY', timezone: 'UTC-7' },
]
const access = useAccessPreviewStore()
const editorOpen = ref(false)
const editorMode = ref<'CREATE' | 'EDIT'>('CREATE')
const createType = ref<WarehouseType>('FBA')
const saving = ref(false)
const loading = ref(false)
const editLoading = ref(false)
const page = ref(1)
const pageSize = ref(10)
const pageSize = ref(20)
const total = ref(0)
const loaded = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const detailWarningCount = ref(0)
const statusLoadingIds = ref(new Set<string>())
const importOpen = ref(false)
const importFile = ref<File | null>(null)
const importMode = ref<WarehouseImportMode>('CREATE_ONLY')
const importResult = ref<WarehouseImportResult | null>(null)
const importing = ref(false)
const downloadingTemplate = ref(false)
type WarehouseFilters = QueryModel & { keyword: string; warehouseType: WarehouseType | ''; status: Warehouse['status'] | '' }
const filters = ref<WarehouseFilters>({ keyword: '', warehouseType: '', status: '' })
const appliedFilters = ref<WarehouseFilters>({ ...filters.value })
@@ -63,20 +155,20 @@ interface WarehouseRow extends Warehouse {
warehouseType: WarehouseType
countryCode: string
timezone: string
postalCode: string
detail: PlatformWarehouseDetailResource | null
}
const rows = ref<WarehouseRow[]>([])
const warehouseColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'warehouse', label: '仓库', minWidth: 200, slotName: 'warehouse' },
{ key: 'warehouseType', label: '仓库类型', width: 130, slotName: 'warehouseType' },
{ prop: 'countryCode', label: '国家/地区码', width: 120 },
{ prop: 'timezone', label: '时区', minWidth: 170 },
{ key: 'city', label: '所在城市', minWidth: 130, slotName: 'city' },
{ key: 'address', label: '收货地址', minWidth: 260, slotName: 'address' },
{ key: 'warehouse', label: '仓库', minWidth: 190, slotName: 'warehouse' },
{ key: 'warehouseType', label: '仓库类型', width: 120, slotName: 'warehouseType' },
{ key: 'countryCode', label: '国家/地区', width: 140, slotName: 'countryCode' },
{ prop: 'timezone', label: '时区', width: 110 },
{ key: 'city', label: '所在城市', minWidth: 140, slotName: 'city' },
{ key: 'address', label: '地址', minWidth: 280, slotName: 'address' },
{ key: 'contact', label: '联系人', minWidth: 150, slotName: 'contact' },
{ prop: 'businessHours', label: '收货时间', minWidth: 190, formatter: (row: WarehouseRow) => row.businessHours || '-' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
]
@@ -84,27 +176,67 @@ type WarehouseForm = Omit<Warehouse, 'updatedAt'> & {
warehouseType: WarehouseType
countryCode: string
timezone: string
postalCode: string
}
const emptyForm = (): WarehouseForm => ({
id: '', code: '', name: '', province: '', city: '', address: '', contactName: '', contactPhone: '',
warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai',
businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '',
function generatedCode(type: WarehouseType) {
const prefix = type === 'DOMESTIC' ? 'CN' : type === 'FBA' ? 'FBA' : 'US'
return `${prefix}-WH-${Date.now().toString(36).slice(-7).toUpperCase()}`
}
const emptyForm = (type: WarehouseType = 'FBA'): WarehouseForm => ({
id: '',
code: type === 'FBA' ? '' : generatedCode(type),
name: '',
province: '',
city: '',
address: '',
postalCode: '',
contactName: '',
contactPhone: '',
warehouseType: type,
countryCode: type === 'DOMESTIC' ? 'CN' : 'US',
timezone: type === 'DOMESTIC' ? 'UTC+8' : '',
businessHours: type === 'FBA' ? '' : '周一至周六 08:30-18:00',
status: 'ACTIVE',
remark: '',
})
const form = reactive(emptyForm())
type WarehouseRequiredField = 'code' | 'name' | 'countryCode' | 'timezone' | 'city' | 'address'
type WarehouseRequiredField = 'code' | 'name' | 'province' | 'countryCode' | 'timezone' | 'city' | 'postalCode' | 'address'
const requiredFieldMessages: Record<WarehouseRequiredField, string> = {
code: '请输入仓库编码',
name: '请输入仓库名称',
countryCode: '请输入国家或地区二字码',
timezone: '请输入 IANA 时区',
province: '请选择或输入州/省',
countryCode: '请选择国家或地区',
timezone: '请输入时区',
city: '请输入城市',
postalCode: '请输入邮编',
address: '请输入详细地址',
}
const formErrors = reactive<Record<WarehouseRequiredField, string>>({
code: '', name: '', countryCode: '', timezone: '', city: '', address: '',
code: '', name: '', province: '', countryCode: '', timezone: '', city: '', postalCode: '', address: '',
})
const editorLabel = computed(() => form.id ? '编辑公共仓库' : '新增公共仓库')
const editorPermission = computed(() => form.id ? 'platform:warehouse:edit' : 'platform:warehouse:create')
const editorLabel = computed(() => editorMode.value === 'EDIT'
? `编辑${warehouseTypeLabels[form.warehouseType]}`
: `新增${warehouseTypeLabels[createType.value]}`)
const editorPermission = computed(() => editorMode.value === 'EDIT' ? 'platform:warehouse:edit' : 'platform:warehouse:create')
const isFbaCreate = computed(() => editorMode.value === 'CREATE' && createType.value === 'FBA')
const isDomesticCreate = computed(() => editorMode.value === 'CREATE' && createType.value === 'DOMESTIC')
const activeWarehouseType = computed(() => editorMode.value === 'EDIT' ? form.warehouseType : createType.value)
const isDomesticForm = computed(() => activeWarehouseType.value === 'DOMESTIC')
const usesUsStateSelect = computed(() => !isDomesticForm.value && form.countryCode === 'US')
const addressRegionLabel = computed(() => usesUsStateSelect.value || isFbaCreate.value ? '州' : '省 / 州')
const regionPlaceholder = computed(() => isDomesticForm.value ? '例如 浙江省' : usesUsStateSelect.value ? '请选择州' : '例如 Ontario')
const cityPlaceholder = computed(() => isDomesticForm.value ? '例如 义乌市' : '例如 Long Beach')
const addressPlaceholder = computed(() => isFbaCreate.value ? '请输入街道和门牌信息' : '请输入街道、园区、楼栋及门牌信息')
const canCommitImport = computed(() => Boolean(
importFile.value
&& importResult.value
&& !importResult.value.committed
&& importResult.value.errorCount === 0
&& (importMode.value === 'CREATE_ONLY' || access.can('platform:warehouse:edit')),
))
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'warehouse-editor',
label: editorLabel,
@@ -114,15 +246,6 @@ const { captureBaseline } = useUnsavedWorkRegistration({
})
const warehouseReady = computed(() => loaded.value && !loading.value)
const filteredRows = computed(() => rows.value.filter((warehouse) => {
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
const matchesKeyword = !keyword || [warehouse.code, warehouse.name, warehouse.province, warehouse.city, warehouse.address]
.some((value) => value.toLowerCase().includes(keyword))
return matchesKeyword
&& (!appliedFilters.value.warehouseType || warehouse.warehouseType === appliedFilters.value.warehouseType)
&& (!appliedFilters.value.status || warehouse.status === appliedFilters.value.status)
}))
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const loadErrorDescription = computed(() => {
if (!loadError.value) return ''
const references = [
@@ -143,10 +266,28 @@ watch(
},
{ flush: 'sync' },
)
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage === previousPage && nextSize === previousSize) return
void loadWarehouses()
})
watch(importMode, () => { importResult.value = null })
watch(activeWarehouseType, (type) => {
if (type === 'DOMESTIC') {
form.countryCode = 'CN'
form.timezone = 'UTC+8'
} else if (form.countryCode === 'CN') {
form.countryCode = 'US'
form.province = ''
form.timezone = ''
}
clearFieldError('countryCode')
clearFieldError('timezone')
})
function toRow(warehouse: PlatformWarehouseResource, detail: PlatformWarehouseDetailResource | null): WarehouseRow {
const address = detail?.addresses[0]
const receiving = detail?.capabilities.find((item) => item.capabilityCode === 'RECEIVING')
function toRow(warehouse: PlatformWarehouseSummaryResource): WarehouseRow {
const address = warehouse.primaryAddress
const addressText = warehouse.addressSummary
|| [address?.district, address?.addressLine1, address?.addressLine2].filter(Boolean).join(' ')
return {
id: String(warehouse.id),
code: warehouse.warehouseCode,
@@ -156,14 +297,15 @@ function toRow(warehouse: PlatformWarehouseResource, detail: PlatformWarehouseDe
timezone: warehouse.timezone,
province: address?.stateProvince || '',
city: address?.city || '',
address: [address?.district, address?.addressLine1, address?.addressLine2].filter(Boolean).join(' '),
address: addressText,
postalCode: address?.postalCode || '',
contactName: warehouse.contactName || '',
contactPhone: warehouse.contactPhone || '',
businessHours: receiving?.description || (receiving?.cutoffTime ? `每日截单 ${receiving.cutoffTime}` : ''),
businessHours: '',
status: warehouse.status,
remark: warehouse.remark || '',
updatedAt: warehouse.updatedAt?.replace('T', ' ').slice(0, 16) || '',
detail,
detail: null,
}
}
@@ -173,8 +315,8 @@ function queryWarehouses(model: QueryModel) {
warehouseType: (model.warehouseType || '') as WarehouseType | '',
status: (model.status || '') as Warehouse['status'] | '',
}
page.value = 1
void loadWarehouses()
if (page.value === 1) void loadWarehouses()
else page.value = 1
}
function clearWarehouseFormErrors() {
@@ -185,15 +327,56 @@ function clearFieldError(field: WarehouseRequiredField) {
if (formErrors[field]) formErrors[field] = ''
}
function validateRequiredField(field: WarehouseRequiredField) {
formErrors[field] = form[field].trim() ? '' : requiredFieldMessages[field]
return !formErrors[field]
function formatCountryDisplay(countryCode: string) {
const code = countryCode.trim().toUpperCase()
if (!code) return '-'
const label = countryDisplayLabels[code]
return label ? `${label} ${code}` : code
}
function validateFields(fields: WarehouseRequiredField[]) {
return fields.map((field) => {
formErrors[field] = form[field].trim() ? '' : requiredFieldMessages[field]
return !formErrors[field]
}).every(Boolean)
}
function validateWarehouseForm() {
return (Object.keys(requiredFieldMessages) as WarehouseRequiredField[])
.map(validateRequiredField)
.every(Boolean)
if (isFbaCreate.value) return validateFields(['code', 'province', 'city', 'postalCode', 'timezone', 'address'])
const fields: WarehouseRequiredField[] = ['code', 'name', 'countryCode', 'timezone', 'city', 'address']
if (usesUsStateSelect.value) fields.splice(4, 0, 'province')
return validateFields(fields)
}
function onCountryChange(value: string) {
form.countryCode = value.toUpperCase()
clearFieldError('countryCode')
if (!usesUsStateSelect.value) {
form.province = ''
form.timezone = ''
clearFieldError('province')
return
}
const matchedState = usStateOptions.find((state) => state.value === form.province.toUpperCase())
if (!matchedState) {
form.province = ''
form.timezone = ''
return
}
onRegionChange(matchedState.value)
}
function onRegionChange(value: string) {
form.province = value.toUpperCase()
clearFieldError('province')
if (!usesUsStateSelect.value) return
const matchedState = usStateOptions.find((state) => state.value === form.province)
if (matchedState?.timezone) {
form.timezone = matchedState.timezone
clearFieldError('timezone')
} else {
form.timezone = ''
}
}
async function loadWarehouses() {
@@ -201,18 +384,17 @@ async function loadWarehouses() {
loading.value = true
loadError.value = null
try {
const result = await listPlatformWarehouses(DEFAULT_TENANT_ID, 1, 200)
const details = await Promise.allSettled(
result.records.map((warehouse) => getPlatformWarehouse(DEFAULT_TENANT_ID, warehouse.id)),
)
if (sequence !== loadSequence) return
rows.value = result.records.map((warehouse, index) => {
const detailResult = details[index]
return toRow(warehouse, detailResult?.status === 'fulfilled' ? detailResult.value : null)
const result = await listPlatformWarehouses({
page: page.value,
size: pageSize.value,
keyword: appliedFilters.value.keyword.trim() || undefined,
warehouseType: appliedFilters.value.warehouseType || undefined,
status: appliedFilters.value.status || undefined,
})
detailWarningCount.value = details.filter((item) => item.status === 'rejected').length
if (sequence !== loadSequence) return
rows.value = result.records.map(toRow)
total.value = result.total
loaded.value = true
page.value = 1
} catch (error) {
if (sequence !== loadSequence) return
loadError.value = getApiErrorDetails(error, '仓库数据加载失败,请稍后重试')
@@ -229,9 +411,11 @@ function requireWarehouseReady() {
return true
}
function openCreate() {
function openCreate(type: WarehouseType) {
if (!requireWarehouseReady()) return
Object.assign(form, emptyForm())
editorMode.value = 'CREATE'
createType.value = type
Object.assign(form, emptyForm(type))
clearWarehouseFormErrors()
captureBaseline()
editorOpen.value = true
@@ -241,9 +425,8 @@ async function ensureDetail(warehouse: WarehouseRow) {
if (warehouse.detail) return warehouse.detail
if (!requireWarehouseReady()) return null
try {
const detail = await getPlatformWarehouse(DEFAULT_TENANT_ID, warehouse.id)
Object.assign(warehouse, toRow(detail.warehouse, detail))
detailWarningCount.value = rows.value.filter((item) => !item.detail).length
const detail = await getPlatformWarehouse(warehouse.id)
warehouse.detail = detail
return detail
} catch (error) {
const details = getApiErrorDetails(error, '仓库详情加载失败,请重试')
@@ -258,13 +441,24 @@ async function openEdit(warehouse: WarehouseRow) {
const detail = await ensureDetail(warehouse)
if (!detail || !loaded.value) return
const address = detail.addresses[0]
Object.assign(form, warehouse, {
province: address?.stateProvince || '',
city: address?.city || '',
address: address?.addressLine1 || '',
const receiving = detail.capabilities.find((item) => item.capabilityCode === 'RECEIVING')
editorMode.value = 'EDIT'
Object.assign(form, {
id: String(detail.warehouse.id),
code: detail.warehouse.warehouseCode,
name: detail.warehouse.warehouseName,
warehouseType: detail.warehouse.warehouseType,
countryCode: detail.warehouse.countryCode,
timezone: detail.warehouse.timezone,
province: address?.stateProvince || '',
city: address?.city || '',
address: address?.addressLine1 || '',
postalCode: address?.postalCode || '',
contactName: detail.warehouse.contactName || '',
contactPhone: detail.warehouse.contactPhone || '',
businessHours: receiving?.description || '',
status: detail.warehouse.status,
remark: detail.warehouse.remark || '',
})
clearWarehouseFormErrors()
captureBaseline()
@@ -278,10 +472,12 @@ function nullable(value: string) {
return value.trim() || null
}
function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseRequest {
function buildSaveRequest(): SavePlatformWarehouseRequest {
const existing = form.id ? rows.value.find((item) => item.id === form.id)?.detail : null
const warehouse = existing?.warehouse
const currentAddress = existing?.addresses[0]
const countryCode = isDomesticForm.value ? 'CN' : form.countryCode.trim().toUpperCase()
const timezone = isDomesticForm.value ? 'UTC+8' : form.timezone.trim()
const capabilities = (existing?.capabilities || []).map((capability) => ({
capabilityCode: capability.capabilityCode,
capabilityName: capability.capabilityName,
@@ -289,22 +485,18 @@ function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseReque
cutoffTime: capability.cutoffTime,
description: capability.capabilityCode === 'RECEIVING' ? nullable(form.businessHours) : capability.description,
}))
if (!capabilities.some((item) => item.capabilityCode === 'RECEIVING')) {
if (form.warehouseType !== 'FBA' && !capabilities.some((item) => item.capabilityCode === 'RECEIVING')) {
capabilities.push({
capabilityCode: 'RECEIVING',
capabilityName: '收货服务',
enabled: true,
cutoffTime: null,
capabilityCode: 'RECEIVING', capabilityName: '收货服务', enabled: true, cutoffTime: null,
description: nullable(form.businessHours),
})
}
return {
tenantId,
warehouseCode: form.code.trim().toUpperCase(),
warehouseName: form.name.trim(),
warehouseType: form.warehouseType,
countryCode: form.countryCode.trim().toUpperCase(),
timezone: form.timezone.trim(),
countryCode,
timezone,
contactName: nullable(form.contactName),
contactPhone: nullable(form.contactPhone),
contactEmail: warehouse?.contactEmail || null,
@@ -312,11 +504,11 @@ function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseReque
remark: nullable(form.remark),
address: {
addressType: currentAddress?.addressType || 'OPERATING',
countryCode: form.countryCode.trim().toUpperCase(),
countryCode,
stateProvince: nullable(form.province),
city: form.city.trim(),
district: currentAddress?.district || null,
postalCode: currentAddress?.postalCode || null,
postalCode: nullable(form.postalCode),
addressLine1: form.address.trim(),
addressLine2: currentAddress?.addressLine2 || null,
latitude: currentAddress?.latitude || null,
@@ -327,17 +519,27 @@ function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseReque
}
async function saveWarehouse() {
if (!requireWarehouseReady()) return
if (!validateWarehouseForm()) return
if (!requireWarehouseReady() || !validateWarehouseForm()) return
saving.value = true
try {
const payload = buildSaveRequest(DEFAULT_TENANT_ID)
if (form.id) await updatePlatformWarehouse(DEFAULT_TENANT_ID, form.id, payload)
else await createPlatformWarehouse(payload)
const successMessage = form.id ? '仓库资料已更新' : '公共仓库已创建'
if (isFbaCreate.value) {
await createFbaWarehouse({
warehouseCode: form.code.trim().toUpperCase(),
stateProvince: form.province.trim().toUpperCase(),
city: form.city.trim(),
postalCode: form.postalCode.trim(),
addressLine1: form.address.trim(),
timezone: form.timezone.trim() || null,
})
} else {
const payload = buildSaveRequest()
if (editorMode.value === 'EDIT') await updatePlatformWarehouse(form.id, payload)
else await createPlatformWarehouse(payload)
}
editorOpen.value = false
ElMessage.success(successMessage)
await loadWarehouses()
ElMessage.success(editorMode.value === 'EDIT' ? '仓库资料已更新' : `${warehouseTypeLabels[createType.value]}已创建`)
if (page.value === 1) await loadWarehouses()
else page.value = 1
} catch (error) {
const details = getApiErrorDetails(error, '仓库保存失败,请检查后重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
@@ -359,7 +561,7 @@ async function toggleStatus(warehouse: WarehouseRow) {
{ confirmButtonText: `确认${targetText}`, cancelButtonText: '取消', type: target === 'INACTIVE' ? 'warning' : 'info' },
)
statusLoadingIds.value = new Set(statusLoadingIds.value).add(warehouse.id)
await changePlatformWarehouseStatus(DEFAULT_TENANT_ID, warehouse.id, target)
await changePlatformWarehouseStatus(warehouse.id, target)
warehouse.status = target
if (warehouse.detail) warehouse.detail.warehouse.status = target
ElMessage.success(`仓库已${targetText}`)
@@ -374,6 +576,91 @@ async function toggleStatus(warehouse: WarehouseRow) {
}
}
function openImport() {
importFile.value = null
importMode.value = 'CREATE_ONLY'
importResult.value = null
importOpen.value = true
}
function selectImportFile(uploadFile: UploadFile) {
const file = uploadFile.raw ?? null
if (!file) return
if (!file.name.toLowerCase().endsWith('.xlsx')) {
importFile.value = null
ElMessage.error('请选择标准 XLSX 文件')
return
}
if (file.size > 5 * 1024 * 1024) {
importFile.value = null
ElMessage.error('文件不得超过 5MB')
return
}
importFile.value = file
importResult.value = null
}
function removeImportFile() {
importFile.value = null
importResult.value = null
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
async function downloadTemplate() {
downloadingTemplate.value = true
try {
const blob = await downloadWarehouseImportTemplate()
saveBlob(blob, '公共仓库导入模板.xlsx')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '模板下载失败,请稍后重试').message)
} finally {
downloadingTemplate.value = false
}
}
async function runImport(dryRun: boolean) {
if (!importFile.value) {
ElMessage.warning('请先选择 XLSX 文件')
return
}
importing.value = true
try {
importResult.value = await importWarehouses(importFile.value, dryRun, importMode.value)
if (dryRun) {
ElMessage.success(importResult.value.errorCount ? '预检完成,请处理错误后重新上传' : '预检通过,可以提交导入')
} else if (importResult.value.committed) {
ElMessage.success('公共仓库已导入')
if (page.value === 1) await loadWarehouses()
else page.value = 1
}
} catch (error) {
ElMessage.error(getApiErrorDetails(error, dryRun ? '文件预检失败,请检查后重试' : '公共仓库导入失败,请重试').message)
} finally {
importing.value = false
}
}
function downloadImportErrors() {
if (!importResult.value?.errors.length) return
const escape = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
const lines = ['行号,字段,错误码,原因', ...importResult.value.errors.map((error) => (
[error.row, error.field, error.code || '-', error.message].map(escape).join(',')
))]
saveBlob(new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), '公共仓库导入错误清单.csv')
}
function formatImportErrorCode(row: WarehouseImportResult['errors'][number]) {
return row.code || '-'
}
onMounted(() => void loadWarehouses())
</script>
@@ -384,89 +671,210 @@ onMounted(() => void loadWarehouses())
<p>{{ loadErrorDescription }}</p>
<el-button size="small" @click="loadWarehouses()">重新查询</el-button>
</el-alert>
<el-alert v-else-if="detailWarningCount" type="warning" :closable="false" show-icon class="state-alert">
<template #title>部分仓库详情暂未加载</template>
<p>{{ detailWarningCount }} 个仓库缺少地址或服务能力详情;编辑时会再次尝试加载,不会用模拟数据补齐。</p>
</el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" @search="queryWarehouses" />
<section class="surface-panel table-panel" aria-live="polite">
<div class="panel-heading">
<div class="panel-heading warehouse-heading">
<div>
<h2>公共仓库</h2>
<p>国内收货仓、FBA 仓和海外仓统一维护 · 共 {{ filteredRows.length }} 个</p>
<p>国内收货仓、FBA 地址和海外仓统一维护 · 共 {{ total }} 个</p>
</div>
<div v-if="access.can('platform:warehouse:create')" class="warehouse-primary-actions">
<el-button :disabled="!warehouseReady" @click="openImport"><Upload :size="16" />导入仓库</el-button>
<el-dropdown trigger="click" :disabled="!warehouseReady" @command="openCreate($event as WarehouseType)">
<el-button type="primary" :disabled="!warehouseReady"><Plus :size="16" />新增仓库<ChevronDown :size="15" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="FBA"><FileSpreadsheet :size="15" />新增 FBA 地址</el-dropdown-item>
<el-dropdown-item command="DOMESTIC"><WarehouseIcon :size="15" />新增国内仓</el-dropdown-item>
<el-dropdown-item command="OVERSEAS"><MapPin :size="15" />新增海外仓</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-button
v-if="access.can('platform:warehouse:create')"
type="primary"
:disabled="!warehouseReady"
@click="openCreate"
><Plus :size="16" />新增仓库</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="pagedRows"
:data="rows"
:columns="warehouseColumns"
:loading="loading"
:total="filteredRows.length"
:page-sizes="[10, 20, 50]"
:total="total"
:page-sizes="[20, 50, 100]"
row-key="id"
aria-label="公共仓库列表"
:empty-text="loaded ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'"
>
<template #actions="{ row }">
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions">
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!warehouseReady" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:warehouse:status')"
link
:type="(row as WarehouseRow).status === 'ACTIVE' ? 'danger' : 'success'"
:disabled="!warehouseReady"
:loading="statusLoadingIds.has((row as WarehouseRow).id)"
@click="toggleStatus(row as WarehouseRow)"
><Power :size="15" />{{ (row as WarehouseRow).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
<span v-else class="warehouse-subline">仅查看</span>
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions">
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!warehouseReady || editLoading" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:warehouse:status')"
link
:type="(row as WarehouseRow).status === 'ACTIVE' ? 'danger' : 'success'"
:disabled="!warehouseReady"
:loading="statusLoadingIds.has((row as WarehouseRow).id)"
@click="toggleStatus(row as WarehouseRow)"
><Power :size="15" />{{ (row as WarehouseRow).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
<span v-else class="warehouse-subline">仅查看</span>
</template>
<template #warehouse="{ row }"><div class="warehouse-cell"><span><WarehouseIcon :size="18" /></span><div><strong>{{ (row as WarehouseRow).name }}</strong><small>{{ (row as WarehouseRow).code }}</small></div></div></template>
<template #warehouseType="{ row }"><el-tag effect="plain">{{ warehouseTypeLabels[(row as WarehouseRow).warehouseType] }}</el-tag></template>
<template #countryCode="{ row }">{{ formatCountryDisplay((row as WarehouseRow).countryCode) }}</template>
<template #city="{ row }">{{ [(row as WarehouseRow).province, (row as WarehouseRow).city].filter(Boolean).join(' · ') || '-' }}</template>
<template #address="{ row }"><div class="warehouse-address"><MapPin :size="15" />{{ (row as WarehouseRow).address || '详情暂未加载' }}</div></template>
<template #address="{ row }"><div class="warehouse-address"><MapPin :size="15" /><span>{{ (row as WarehouseRow).address || '-' }}<small v-if="(row as WarehouseRow).postalCode">{{ (row as WarehouseRow).postalCode }}</small></span></div></template>
<template #contact="{ row }"><strong>{{ (row as WarehouseRow).contactName || '-' }}</strong><span class="warehouse-subline">{{ (row as WarehouseRow).contactPhone || '-' }}</span></template>
<template #status="{ row }"><el-tag :type="(row as WarehouseRow).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as WarehouseRow).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #pagination-left>共 {{ filteredRows.length }} 个公共仓库</template>
<template #pagination-left="{ total: tableTotal }">共 {{ tableTotal }} 个公共仓库</template>
</DataTable>
</section>
<el-drawer v-model="editorOpen" :title="editorLabel" size="620px" class="business-drawer editor-drawer" :close-on-click-modal="false">
<el-form :model="form" label-position="top" class="drawer-form" :disabled="saving">
<div class="form-section"><h3>仓库标识</h3><div class="form-grid two-cols"><el-form-item label="仓库编码" required :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="例如 CN-NB-01" :aria-invalid="Boolean(formErrors.code)" :aria-describedby="formErrors.code ? 'warehouse-code-error' : undefined" @input="clearFieldError('code')" @blur="validateRequiredField('code')" /><p v-if="formErrors.code" id="warehouse-code-error" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item><el-form-item label="仓库名称" required :class="{ 'has-field-error': formErrors.name }"><el-input v-model="form.name" maxlength="120" placeholder="请输入客户下单时显示的名称" :aria-invalid="Boolean(formErrors.name)" :aria-describedby="formErrors.name ? 'warehouse-name-error' : undefined" @input="clearFieldError('name')" @blur="validateRequiredField('name')" /><p v-if="formErrors.name" id="warehouse-name-error" class="field-error" role="alert">{{ formErrors.name }}</p></el-form-item><el-form-item label="仓库类型" required><el-select v-model="form.warehouseType" placeholder="请选择仓库类型"><el-option v-for="(label, value) in warehouseTypeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item><el-form-item label="状态"><el-radio-group v-model="form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item><el-form-item label="国家或地区二字码" required :class="{ 'has-field-error': formErrors.countryCode }"><el-input v-model="form.countryCode" maxlength="2" placeholder="例如 CN、US" :aria-invalid="Boolean(formErrors.countryCode)" :aria-describedby="formErrors.countryCode ? 'warehouse-country-error' : undefined" @input="form.countryCode = String($event).toUpperCase(); clearFieldError('countryCode')" @blur="validateRequiredField('countryCode')" /><p v-if="formErrors.countryCode" id="warehouse-country-error" class="field-error" role="alert">{{ formErrors.countryCode }}</p></el-form-item><el-form-item label="IANA 时区" required :class="{ 'has-field-error': formErrors.timezone }"><el-input v-model="form.timezone" maxlength="64" placeholder="例如 Asia/Shanghai" :aria-invalid="Boolean(formErrors.timezone)" :aria-describedby="formErrors.timezone ? 'warehouse-timezone-error' : undefined" @input="clearFieldError('timezone')" @blur="validateRequiredField('timezone')" /><p v-if="formErrors.timezone" id="warehouse-timezone-error" class="field-error" role="alert">{{ formErrors.timezone }}</p></el-form-item></div></div>
<div class="form-section"><h3>地址与收货</h3><div class="form-grid two-cols"><el-form-item label="省份"><el-input v-model="form.province" maxlength="100" placeholder="例如 广东省" /></el-form-item><el-form-item label="城市" required :class="{ 'has-field-error': formErrors.city }"><el-input v-model="form.city" maxlength="100" placeholder="例如 深圳市" :aria-invalid="Boolean(formErrors.city)" :aria-describedby="formErrors.city ? 'warehouse-city-error' : undefined" @input="clearFieldError('city')" @blur="validateRequiredField('city')" /><p v-if="formErrors.city" id="warehouse-city-error" class="field-error" role="alert">{{ formErrors.city }}</p></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="请输入仓库联系人姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" placeholder="请输入手机号或固定电话" /></el-form-item><el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="500" placeholder="例如 周一至周六 08:30-18:00" /></el-form-item></div><el-form-item label="详细地址" required :class="{ 'has-field-error': formErrors.address }"><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" placeholder="请输入街道、园区、楼栋及库位信息" :aria-invalid="Boolean(formErrors.address)" :aria-describedby="formErrors.address ? 'warehouse-address-error' : undefined" @input="clearFieldError('address')" @blur="validateRequiredField('address')" /><p v-if="formErrors.address" id="warehouse-address-error" class="field-error" role="alert">{{ formErrors.address }}</p></el-form-item></div>
<div class="form-section"><h3>备注</h3><el-form-item label="客户可理解的收货说明"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit placeholder="例如 预约要求、进仓指引或其他注意事项" /></el-form-item></div>
<div class="form-section">
<h3>基础信息</h3>
<div class="form-grid two-cols">
<el-form-item v-if="isFbaCreate" label="FBA 编码" required :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="例如 LAX9" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item>
<el-form-item v-if="isFbaCreate" label="国家或地区" required>
<el-select v-model="form.countryCode" disabled placeholder="美国 US">
<el-option label="美国 US" value="US" />
</el-select>
</el-form-item>
<template v-else>
<el-form-item label="仓库名称" required :class="{ 'has-field-error': formErrors.name }"><el-input v-model="form.name" maxlength="120" placeholder="请输入仓库名称" @input="clearFieldError('name')" /><p v-if="formErrors.name" class="field-error" role="alert">{{ formErrors.name }}</p></el-form-item>
<el-form-item v-if="editorMode === 'EDIT'" label="仓库类型" required><el-select v-model="form.warehouseType"><el-option v-for="(label, value) in warehouseTypeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item v-if="!isDomesticCreate" label="国家或地区" required :class="{ 'has-field-error': formErrors.countryCode }">
<el-select v-if="isDomesticForm" v-model="form.countryCode" disabled placeholder="中国 CN"><el-option label="中国 CN" value="CN" /></el-select>
<el-select v-else v-model="form.countryCode" filterable placeholder="请选择国家或地区" @change="onCountryChange"><el-option v-for="country in countryOptions" :key="country.value" :label="country.label" :value="country.value" /></el-select>
<p v-if="formErrors.countryCode" class="field-error" role="alert">{{ formErrors.countryCode }}</p>
</el-form-item>
<el-form-item v-if="!isDomesticCreate" label="时区" required :class="{ 'has-field-error': formErrors.timezone }">
<el-select v-if="isDomesticForm" v-model="form.timezone" disabled placeholder="UTC+8"><el-option label="UTC+8" value="UTC+8" /></el-select>
<el-select v-else v-model="form.timezone" filterable placeholder="请选择时区" @change="clearFieldError('timezone')"><el-option v-for="timezone in timezoneOptions" :key="timezone" :label="timezone" :value="timezone" /></el-select>
<p v-if="formErrors.timezone" class="field-error" role="alert">{{ formErrors.timezone }}</p>
</el-form-item>
</template>
</div>
</div>
<div class="form-section">
<h3>地址信息</h3>
<div class="form-grid two-cols">
<el-form-item :label="addressRegionLabel" :required="isFbaCreate || usesUsStateSelect" :class="{ 'has-field-error': formErrors.province }">
<el-select v-if="usesUsStateSelect" v-model="form.province" filterable :placeholder="regionPlaceholder" @change="onRegionChange"><el-option v-for="state in usStateOptions" :key="state.value" :label="state.label" :value="state.value" /></el-select>
<el-input v-else v-model="form.province" maxlength="100" :placeholder="regionPlaceholder" @input="isFbaCreate && (form.province = String($event).toUpperCase()); clearFieldError('province')" />
<p v-if="formErrors.province" class="field-error" role="alert">{{ formErrors.province }}</p>
</el-form-item>
<el-form-item label="城市" required :class="{ 'has-field-error': formErrors.city }"><el-input v-model="form.city" maxlength="100" :placeholder="cityPlaceholder" @input="clearFieldError('city')" /><p v-if="formErrors.city" class="field-error" role="alert">{{ formErrors.city }}</p></el-form-item>
<el-form-item label="邮编" :required="isFbaCreate" :class="{ 'has-field-error': formErrors.postalCode }"><el-input v-model="form.postalCode" maxlength="20" :placeholder="isDomesticForm ? '例如 322000' : '例如 90802'" @input="clearFieldError('postalCode')" /><p v-if="formErrors.postalCode" class="field-error" role="alert">{{ formErrors.postalCode }}</p></el-form-item>
</div>
<el-form-item label="详细地址" required :class="{ 'has-field-error': formErrors.address }"><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" :placeholder="addressPlaceholder" @input="clearFieldError('address')" /><p v-if="formErrors.address" class="field-error" role="alert">{{ formErrors.address }}</p></el-form-item>
</div>
<section v-if="!isFbaCreate" class="form-section">
<h3>编码与状态</h3>
<div class="form-grid two-cols compact-grid">
<el-form-item label="仓库编码" :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="系统自动生成" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item>
<el-form-item label="状态"><el-radio-group v-model="form.status" class="warehouse-status-toggle"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item>
</div>
</section>
<section v-if="!isFbaCreate" class="form-section">
<h3>联系信息</h3>
<div class="form-grid two-cols compact-grid">
<el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="选填" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" placeholder="选填" /></el-form-item>
<el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="500" placeholder="例如 周一至周六 08:30-18:00" /></el-form-item>
</div>
<el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit placeholder="选填" /></el-form-item>
</section>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveWarehouse">保存仓库</el-button></div></template>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveWarehouse">{{ editorMode === 'EDIT' ? '保存修改' : '确认新增' }}</el-button></div></template>
</el-drawer>
<el-dialog v-model="importOpen" title="导入公共仓库" width="min(760px, calc(100vw - 32px))" :close-on-click-modal="!importing" :close-on-press-escape="!importing">
<div class="import-content">
<div class="import-toolbar">
<el-button :loading="downloadingTemplate" @click="downloadTemplate"><Download :size="16" />下载 XLSX 模板</el-button>
<el-radio-group v-model="importMode" :disabled="importing">
<el-radio-button value="CREATE_ONLY">仅新增</el-radio-button>
<el-radio-button value="UPSERT" :disabled="!access.can('platform:warehouse:edit')">新增并更新</el-radio-button>
</el-radio-group>
</div>
<el-upload accept=".xlsx" :auto-upload="false" :limit="1" :on-change="selectImportFile" :on-remove="removeImportFile">
<el-button><Upload :size="16" />选择 XLSX 文件</el-button>
<template #tip><div class="el-upload__tip">最多 5MB、1000 行;请使用标准模板,FBA 名称可不填并默认使用编码。</div></template>
</el-upload>
<div v-if="importResult" class="import-result" role="status">
<dl class="import-summary">
<div><dt>总行数</dt><dd>{{ importResult.totalRows }}</dd></div>
<div><dt>有效</dt><dd>{{ importResult.validRows }}</dd></div>
<div><dt>新增</dt><dd>{{ importResult.createCount }}</dd></div>
<div><dt>更新</dt><dd>{{ importResult.updateCount }}</dd></div>
<div><dt>无变化</dt><dd>{{ importResult.unchangedCount }}</dd></div>
<div><dt>错误</dt><dd :class="{ 'is-error': importResult.errorCount }">{{ importResult.errorCount }}</dd></div>
</dl>
<div class="import-result-heading"><strong>{{ importResult.committed ? '导入已提交' : (importResult.errorCount ? '预检未通过,未写入数据' : '预检通过,尚未写入数据') }}</strong><el-button v-if="importResult.errors.length" size="small" @click="downloadImportErrors"><Download :size="15" />下载错误清单</el-button></div>
<el-table v-if="importResult.errors.length" :data="importResult.errors" max-height="260" size="small">
<el-table-column prop="row" label="行号" width="70" /><el-table-column prop="field" label="字段" width="110" /><el-table-column prop="code" label="错误码" width="130" :formatter="formatImportErrorCode" /><el-table-column prop="message" label="原因" min-width="220" />
</el-table>
</div>
</div>
<template #footer><el-button :disabled="importing" @click="importOpen = false">关闭</el-button><el-button :loading="importing" :disabled="!importFile" @click="runImport(true)">预检文件</el-button><el-button type="primary" :loading="importing" :disabled="!canCommitImport" @click="runImport(false)">确认导入</el-button></template>
</el-dialog>
</div>
</template>
<style scoped>
.state-alert p { margin: 6px 0 10px; line-height: 1.55; }
.warehouse-table-state { min-height: 180px; }
.warehouse-heading { gap: 16px; }
.warehouse-primary-actions, .import-toolbar, .import-result-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.warehouse-cell { display: flex; align-items: center; gap: 10px; }
.warehouse-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
.warehouse-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 8px; background: #eef3fa; color: var(--cc-primary); }
.warehouse-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.warehouse-cell strong { overflow-wrap: anywhere; }
.warehouse-cell small, .warehouse-subline { display: block; color: var(--cc-muted); font-size: 11px; }
.warehouse-cell small, .warehouse-subline, .warehouse-address small { display: block; color: var(--cc-muted); font-size: 11px; }
.warehouse-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; }
.warehouse-address svg { flex: 0 0 15px; margin-top: 2px; color: var(--cc-muted); }
.warehouse-address span { min-width: 0; }
.field-error { width: 100%; margin: 6px 0 0; color: var(--el-color-danger); font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; }
.compact-grid { row-gap: 10px; }
.warehouse-status-toggle :deep(.el-radio-button__inner) { min-width: 74px; }
.import-content { display: grid; gap: 20px; }
.import-toolbar { justify-content: space-between; }
.import-result { display: grid; gap: 14px; padding-top: 16px; border-top: 1px solid var(--cc-border); }
.import-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); border-radius: 8px; overflow: hidden; }
.import-summary div { min-width: 0; padding: 10px 12px; border-right: 1px solid var(--cc-border); background: var(--cc-surface-soft, #f8fafc); }
.import-summary div:last-child { border-right: 0; }
.import-summary dt { color: var(--cc-muted); font-size: 11px; }
.import-summary dd { margin: 3px 0 0; color: var(--cc-text); font-size: 16px; font-weight: 700; }
.import-summary dd.is-error { color: var(--el-color-danger); }
.import-result-heading { justify-content: space-between; }
:deep(.has-field-error .el-input__wrapper) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
:deep(.has-field-error .el-textarea__inner) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
:global(.el-dropdown-menu__item) { gap: 8px; }
@media (max-width: 720px) {
.panel-heading .el-button { width: 100%; }
.warehouse-heading { align-items: stretch; }
.warehouse-primary-actions { width: 100%; }
.warehouse-primary-actions > .el-button, .warehouse-primary-actions > .el-dropdown { flex: 1 1 150px; }
.warehouse-primary-actions :deep(.el-dropdown .el-button) { width: 100%; min-height: 44px; }
.warehouse-primary-actions > .el-button { min-height: 44px; }
.import-toolbar { align-items: stretch; flex-direction: column; }
.import-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.import-summary div:nth-child(3) { border-right: 0; }
.import-summary div:nth-child(-n+3) { border-bottom: 1px solid var(--cc-border); }
}
@media (max-width: 420px) {
.warehouse-primary-actions { flex-direction: column; }
.warehouse-primary-actions > .el-button, .warehouse-primary-actions > .el-dropdown { width: 100%; flex-basis: auto; }
.import-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.import-summary div:nth-child(3) { border-right: 1px solid var(--cc-border); }
.import-summary div:nth-child(2n) { border-right: 0; }
.import-summary div:nth-child(-n+4) { border-bottom: 1px solid var(--cc-border); }
}
</style>