908 lines
45 KiB
Vue
908 lines
45 KiB
Vue
<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>
|