Files
admin-web/src/modules/warehouses/pages/WarehousesView.vue
T
2026-09-09 17:42:19 +08:00

908 lines
45 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ChevronDown, Download, FileSpreadsheet, FileUp, MapPin, Pencil, Plus, Power, Save, Warehouse as WarehouseIcon } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.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 PlatformWarehouseSummaryResource,
type SavePlatformWarehouseRequest,
type WarehouseType,
} from '@/modules/warehouses/api/platformWarehouses'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import type { Warehouse } from '@/modules/warehouses/types'
const warehouseTypeLabels: Record<WarehouseType, string> = {
DOMESTIC: '国内收货仓',
FBA: 'FBA 地址',
OVERSEAS: '海外仓',
OVERSEAS_PARTNER: '海外仓合作仓',
}
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(20)
const total = ref(0)
const loaded = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
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 })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '仓库关键词', placeholder: '仓库名称、编码、城市或地址' },
{
key: 'warehouseType', type: 'select', label: '仓库类型', placeholder: '全部仓库类型', options: [
{ label: warehouseTypeLabels.DOMESTIC, value: 'DOMESTIC' },
{ label: warehouseTypeLabels.FBA, value: 'FBA' },
{ label: warehouseTypeLabels.OVERSEAS, value: 'OVERSEAS' },
{ label: warehouseTypeLabels.OVERSEAS_PARTNER, value: 'OVERSEAS_PARTNER' },
],
},
{
key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
{ label: '启用', value: 'ACTIVE' },
{ label: '停用', value: 'INACTIVE' },
],
},
]
let loadSequence = 0
interface WarehouseRow extends Warehouse {
warehouseType: WarehouseType
internalCode: string
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: 190, slotName: 'warehouse' },
{ key: 'code', prop: 'code', label: '仓库编码', minWidth: 145 },
{ key: 'internalCode', prop: 'internalCode', label: '内部编号', minWidth: 120 },
{ key: 'warehouseType', label: '仓库类型', width: 120, slotName: 'warehouseType' },
{ key: 'countryCode', label: '国家/地区', width: 140, slotName: 'countryCode' },
{ key: 'timezone', prop: 'timezone', label: '时区', width: 110, defaultVisible: false },
{ key: 'province', prop: 'province', label: '州/省', minWidth: 130 },
{ key: 'city', prop: 'city', label: '城市', minWidth: 140 },
{ key: 'postalCode', prop: 'postalCode', label: '邮编', minWidth: 110 },
{ key: 'address', label: '地址', minWidth: 280, slotName: 'address', defaultVisible: false },
{ key: 'contact', label: '联系人', minWidth: 150, slotName: 'contact', defaultVisible: false },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
]
type WarehouseForm = Omit<Warehouse, 'updatedAt'> & {
warehouseType: WarehouseType
internalCode: string
countryCode: string
timezone: string
postalCode: string
receivingCutoffTime: string
}
function generatedCode(type: WarehouseType) {
const prefix = type === 'DOMESTIC' ? 'CN' : type === 'FBA' ? 'FBA' : type === 'OVERSEAS_PARTNER' ? 'PARTNER' : 'US'
return `${prefix}-WH-${Date.now().toString(36).slice(-7).toUpperCase()}`
}
const emptyForm = (type: WarehouseType = 'FBA'): WarehouseForm => ({
id: '',
code: type === 'FBA' ? '' : generatedCode(type),
internalCode: '',
name: '',
province: '',
city: '',
address: '',
postalCode: '',
contactName: '',
contactPhone: '',
businessHours: '',
warehouseType: type,
countryCode: type === 'DOMESTIC' ? 'CN' : 'US',
timezone: type === 'DOMESTIC' ? 'UTC+8' : '',
receivingCutoffTime: type === 'FBA' ? '' : '18:00:00',
status: 'ACTIVE',
remark: '',
})
const form = reactive(emptyForm())
type WarehouseRequiredField = 'code' | 'name' | 'province' | 'countryCode' | 'timezone' | 'city' | 'postalCode' | 'address'
const requiredFieldMessages: Record<WarehouseRequiredField, string> = {
code: '请输入仓库编码',
name: '请输入仓库名称',
province: '请选择或输入州/省',
countryCode: '请选择国家或地区',
timezone: '请输入时区',
city: '请输入城市',
postalCode: '请输入邮编',
address: '请输入详细地址',
}
const formErrors = reactive<Record<WarehouseRequiredField, string>>({
code: '', name: '', province: '', countryCode: '', timezone: '', city: '', postalCode: '', address: '',
})
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,
isOpen: editorOpen,
model: form,
requiredPermission: editorPermission,
})
const warehouseReady = computed(() => loaded.value && !loading.value)
const loadErrorDescription = computed(() => {
if (!loadError.value) return ''
const references = [
loadError.value.code ? `错误码 ${loadError.value.code}` : '',
loadError.value.requestId ? `requestId ${loadError.value.requestId}` : '',
loadError.value.traceId ? `traceId ${loadError.value.traceId}` : '',
].filter(Boolean)
return [loadError.value.message, references.join(' · ')].filter(Boolean).join(' ')
})
watch(
() => access.can(editorPermission.value),
(canWrite) => {
if (canWrite || !editorOpen.value) return
editorOpen.value = false
Object.assign(form, emptyForm())
},
{ 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 = ''
}
if (type !== 'FBA') form.internalCode = ''
clearFieldError('countryCode')
clearFieldError('timezone')
})
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,
internalCode: warehouse.internalCode || '',
name: warehouse.warehouseName,
warehouseType: warehouse.warehouseType,
countryCode: warehouse.countryCode,
timezone: warehouse.timezone,
province: address?.stateProvince || '',
city: address?.city || '',
address: addressText,
postalCode: address?.postalCode || '',
contactName: warehouse.contactName || '',
contactPhone: digitsOnly(warehouse.contactPhone),
businessHours: '',
status: warehouse.status,
remark: warehouse.remark || '',
updatedAt: warehouse.updatedAt?.replace('T', ' ').slice(0, 16) || '',
detail: null,
}
}
function queryWarehouses(model: QueryModel) {
appliedFilters.value = {
keyword: String(model.keyword || ''),
warehouseType: (model.warehouseType || '') as WarehouseType | '',
status: (model.status || '') as Warehouse['status'] | '',
}
if (page.value === 1) void loadWarehouses()
else page.value = 1
}
function clearWarehouseFormErrors() {
for (const field of Object.keys(formErrors) as WarehouseRequiredField[]) formErrors[field] = ''
}
function clearFieldError(field: WarehouseRequiredField) {
if (formErrors[field]) 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() {
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() {
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
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,
})
if (sequence !== loadSequence) return
rows.value = result.records.map(toRow)
total.value = result.total
loaded.value = true
} catch (error) {
if (sequence !== loadSequence) return
loadError.value = getApiErrorDetails(error, '仓库数据加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
function requireWarehouseReady() {
if (!warehouseReady.value) {
ElMessage.warning('仓库数据尚未加载完成,请稍后重试')
return false
}
return true
}
function openCreate(type: WarehouseType) {
if (!requireWarehouseReady()) return
editorMode.value = 'CREATE'
createType.value = type
Object.assign(form, emptyForm(type))
clearWarehouseFormErrors()
captureBaseline()
editorOpen.value = true
}
async function ensureDetail(warehouse: WarehouseRow) {
if (warehouse.detail) return warehouse.detail
if (!requireWarehouseReady()) return null
try {
const detail = await getPlatformWarehouse(warehouse.id)
warehouse.detail = detail
return detail
} catch (error) {
const details = getApiErrorDetails(error, '仓库详情加载失败,请重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
return null
}
}
async function openEdit(warehouse: WarehouseRow) {
editLoading.value = true
try {
const detail = await ensureDetail(warehouse)
if (!detail || !loaded.value) return
const address = detail.addresses[0]
const receiving = detail.capabilities.find((item) => item.capabilityCode === 'RECEIVING')
editorMode.value = 'EDIT'
Object.assign(form, {
id: String(detail.warehouse.id),
code: detail.warehouse.warehouseCode,
internalCode: detail.warehouse.internalCode || '',
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: digitsOnly(detail.warehouse.contactPhone),
receivingCutoffTime: receiving?.cutoffTime || '',
status: detail.warehouse.status,
remark: detail.warehouse.remark || '',
})
clearWarehouseFormErrors()
captureBaseline()
editorOpen.value = true
} finally {
editLoading.value = false
}
}
function nullable(value: string) {
return value.trim() || null
}
function digitsOnly(value: unknown) {
return String(value || '').replace(/\D/g, '').slice(0, 32)
}
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,
enabled: capability.enabled ?? true,
cutoffTime: capability.capabilityCode === 'RECEIVING' ? nullable(form.receivingCutoffTime) : capability.cutoffTime,
description: capability.description,
}))
if (form.warehouseType !== 'FBA' && !capabilities.some((item) => item.capabilityCode === 'RECEIVING')) {
capabilities.push({
capabilityCode: 'RECEIVING', capabilityName: '收货服务', enabled: true, cutoffTime: null,
description: null,
})
}
return {
warehouseCode: form.code.trim().toUpperCase(),
internalCode: form.warehouseType === 'FBA' ? nullable(form.internalCode)?.toUpperCase() || null : null,
warehouseName: form.name.trim(),
warehouseType: form.warehouseType,
countryCode,
timezone,
contactName: nullable(form.contactName),
contactPhone: nullable(form.contactPhone),
contactEmail: warehouse?.contactEmail || null,
status: form.status,
remark: nullable(form.remark),
address: {
addressType: currentAddress?.addressType || 'OPERATING',
countryCode,
stateProvince: nullable(form.province),
city: form.city.trim(),
district: currentAddress?.district || null,
postalCode: nullable(form.postalCode),
addressLine1: form.address.trim(),
addressLine2: currentAddress?.addressLine2 || null,
latitude: currentAddress?.latitude || null,
longitude: currentAddress?.longitude || null,
},
capabilities,
}
}
async function saveWarehouse() {
if (!requireWarehouseReady() || !validateWarehouseForm()) return
saving.value = true
try {
if (isFbaCreate.value) {
await createFbaWarehouse({
warehouseCode: form.code.trim().toUpperCase(),
internalCode: nullable(form.internalCode)?.toUpperCase() || null,
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(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)
} finally {
saving.value = false
}
}
async function toggleStatus(warehouse: WarehouseRow) {
if (!requireWarehouseReady()) return
const target = warehouse.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const targetText = target === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(
target === 'INACTIVE'
? `停用“${warehouse.name}”后,客户新建头程订单时将无法选择该公共仓库。`
: `确认重新启用“${warehouse.name}”供客户下单选择吗?`,
`${targetText}公共仓库`,
{ confirmButtonText: `确认${targetText}`, cancelButtonText: '取消', type: target === 'INACTIVE' ? 'warning' : 'info' },
)
statusLoadingIds.value = new Set(statusLoadingIds.value).add(warehouse.id)
await changePlatformWarehouseStatus(warehouse.id, target)
warehouse.status = target
if (warehouse.detail) warehouse.detail.warehouse.status = target
ElMessage.success(`仓库已${targetText}`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
const details = getApiErrorDetails(error, `仓库${targetText}失败,请重试`)
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
const next = new Set(statusLoadingIds.value)
next.delete(warehouse.id)
statusLoadingIds.value = next
}
}
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>
<template>
<AdminPage as="div">
<ListPageLayout aria-label="仓库列表页面">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" :loading="loading" preference-key="warehouses-query" @search="queryWarehouses" />
</template>
<template v-if="loadError" #state>
<LoadErrorState
title="仓库数据加载失败"
:message="loadErrorDescription"
:loading="loading"
retry-text="重新查询"
@retry="loadWarehouses"
/>
</template>
<section v-if="!loadError" class="surface-panel table-panel" aria-live="polite">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="warehouseColumns"
border
stripe
preference-key="warehouses-table"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
row-key="id"
aria-label="公共仓库列表"
:empty-text="loaded ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'"
>
<template #toolbar-actions>
<div v-if="access.can('platform:warehouse:create')" class="warehouse-primary-actions">
<el-button :disabled="!warehouseReady" @click="openImport"><FileUp :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-item command="OVERSEAS_PARTNER"><MapPin :size="15" />新增海外仓合作仓</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
<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')" size="small" plain type="primary" :disabled="!warehouseReady || editLoading" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:warehouse:status')"
size="small"
plain
: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"><div><strong>{{ (row as WarehouseRow).name }}</strong></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 #address="{ row }"><div class="warehouse-address"><MapPin :size="15" /><span>{{ (row as WarehouseRow).address || '-' }}</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="{ total: tableTotal }">共 {{ tableTotal }} 个公共仓库</template>
</DataTable>
</section>
</ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="warehouse-editor-dialog" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close>
<el-form :model="form" label-position="top" class="drawer-form" :disabled="saving">
<FormSection title="基础信息">
<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>
<el-form-item v-if="activeWarehouseType === 'FBA'" label="内部编号"><el-input v-model="form.internalCode" maxlength="16" placeholder="例如 154 或 CADF" @input="form.internalCode = String($event ?? '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 16)" /></el-form-item>
</div>
</FormSection>
<FormSection title="地址信息">
<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>
</FormSection>
<FormSection v-if="!isFbaCreate" title="编码与状态">
<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>
</FormSection>
<FormSection v-if="!isFbaCreate" title="联系信息">
<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" inputmode="numeric" placeholder="选填" @input="form.contactPhone = digitsOnly($event)" /></el-form-item>
<el-form-item label="收货时间"><el-time-picker v-model="form.receivingCutoffTime" value-format="HH:mm:ss" format="HH:mm" placeholder="请选择收货截止时间" /></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>
</FormSection>
</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"><Save :size="16" />{{ editorMode === 'EDIT' ? '保存修改' : '确认新增' }}</el-button></div></template>
</AppDialog>
<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><FileUp :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)"><FileUp :size="16" />确认导入</el-button></template>
</el-dialog>
</AdminPage>
</template>
<style scoped>
.state-alert p { margin: 6px 0 10px; line-height: 1.55; }
.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: 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, .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; }
:global(.warehouse-editor-dialog) { max-height: min(800px, calc(100vh - 32px)); display: flex; flex-direction: column; }
:global(.warehouse-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
:global(.warehouse-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
.warehouse-status-toggle :deep(.el-radio-button__inner) { min-width: 74px; }
@media (max-width: 720px) {
.drawer-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); }
}
.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) {
.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>