Files
admin-web/src/modules/warehouses/pages/WarehousesView.vue
T

885 lines
43 KiB
Vue
Raw Normal View History

2026-07-30 17:59:43 +08:00
<script setup lang="ts">
2026-08-04 11:14:34 +08:00
import { computed, onMounted, reactive, ref, watch } from 'vue'
2026-08-06 13:56:04 +08:00
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
2026-08-10 16:24:54 +08:00
import { ChevronDown, Download, FileSpreadsheet, FileUp, MapPin, Pencil, Plus, Power, Save, Warehouse as WarehouseIcon } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
2026-08-12 16:58:52 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-03 18:49:18 +08:00
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-10 16:24:54 +08:00
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-08-03 18:49:18 +08:00
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'
2026-07-31 13:43:16 +08:00
import {
changePlatformWarehouseStatus,
2026-08-06 13:56:04 +08:00
createFbaWarehouse,
2026-07-31 13:43:16 +08:00
createPlatformWarehouse,
2026-08-06 13:56:04 +08:00
downloadWarehouseImportTemplate,
2026-07-31 13:43:16 +08:00
getPlatformWarehouse,
2026-08-06 13:56:04 +08:00
importWarehouses,
2026-07-31 13:43:16 +08:00
listPlatformWarehouses,
updatePlatformWarehouse,
2026-08-06 13:56:04 +08:00
type WarehouseImportMode,
type WarehouseImportResult,
2026-07-31 13:43:16 +08:00
type PlatformWarehouseDetailResource,
2026-08-06 13:56:04 +08:00
type PlatformWarehouseSummaryResource,
2026-07-31 13:43:16 +08:00
type SavePlatformWarehouseRequest,
2026-08-04 11:14:34 +08:00
type WarehouseType,
2026-08-03 18:49:18 +08:00
} from '@/modules/warehouses/api/platformWarehouses'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import type { Warehouse } from '@/modules/warehouses/types'
2026-07-30 17:59:43 +08:00
2026-08-04 11:14:34 +08:00
const warehouseTypeLabels: Record<WarehouseType, string> = {
DOMESTIC: '国内收货仓',
2026-08-06 13:56:04 +08:00
FBA: 'FBA 地址',
2026-08-04 11:14:34 +08:00
OVERSEAS: '海外仓',
}
2026-08-06 13:56:04 +08:00
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' },
]
2026-08-04 11:14:34 +08:00
2026-07-30 17:59:43 +08:00
const access = useAccessPreviewStore()
const editorOpen = ref(false)
2026-08-06 13:56:04 +08:00
const editorMode = ref<'CREATE' | 'EDIT'>('CREATE')
const createType = ref<WarehouseType>('FBA')
2026-07-31 13:43:16 +08:00
const saving = ref(false)
const loading = ref(false)
2026-08-04 09:05:19 +08:00
const editLoading = ref(false)
2026-07-30 17:59:43 +08:00
const page = ref(1)
2026-08-06 13:56:04 +08:00
const pageSize = ref(20)
const total = ref(0)
2026-08-04 11:14:34 +08:00
const loaded = ref(false)
2026-07-31 13:43:16 +08:00
const loadError = ref<ApiErrorDetails | null>(null)
const statusLoadingIds = ref(new Set<string>())
2026-08-06 13:56:04 +08:00
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)
2026-08-04 11:14:34 +08:00
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[] = [
2026-08-06 19:03:13 +08:00
{ key: 'keyword', type: 'input', label: '仓库关键词', placeholder: '仓库名称、编码、城市或地址' },
2026-08-04 11:14:34 +08:00
{
2026-08-06 19:03:13 +08:00
key: 'warehouseType', type: 'select', label: '仓库类型', placeholder: '全部仓库类型', options: [
2026-08-04 11:14:34 +08:00
{ label: warehouseTypeLabels.DOMESTIC, value: 'DOMESTIC' },
{ label: warehouseTypeLabels.FBA, value: 'FBA' },
{ label: warehouseTypeLabels.OVERSEAS, value: 'OVERSEAS' },
],
},
{
2026-08-06 19:03:13 +08:00
key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
{ label: '启用', value: 'ACTIVE' },
{ label: '停用', value: 'INACTIVE' },
],
},
]
2026-07-31 13:43:16 +08:00
let loadSequence = 0
interface WarehouseRow extends Warehouse {
2026-08-04 11:14:34 +08:00
warehouseType: WarehouseType
countryCode: string
timezone: string
2026-08-06 13:56:04 +08:00
postalCode: string
2026-07-31 13:43:16 +08:00
detail: PlatformWarehouseDetailResource | null
}
const rows = ref<WarehouseRow[]>([])
const warehouseColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
2026-08-06 13:56:04 +08:00
{ key: 'warehouse', label: '仓库', minWidth: 190, slotName: 'warehouse' },
{ key: 'warehouseType', label: '仓库类型', width: 120, slotName: 'warehouseType' },
{ key: 'countryCode', label: '国家/地区', width: 140, slotName: 'countryCode' },
2026-08-12 12:19:02 +08:00
{ key: 'timezone', prop: 'timezone', label: '时区', width: 110, defaultVisible: false },
2026-08-06 13:56:04 +08:00
{ key: 'city', label: '所在城市', minWidth: 140, slotName: 'city' },
2026-08-12 12:19:02 +08:00
{ 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' },
]
2026-07-30 17:59:43 +08:00
2026-08-04 11:14:34 +08:00
type WarehouseForm = Omit<Warehouse, 'updatedAt'> & {
warehouseType: WarehouseType
countryCode: string
timezone: string
2026-08-06 13:56:04 +08:00
postalCode: string
2026-08-04 11:14:34 +08:00
}
2026-08-06 13:56:04 +08:00
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: '',
2026-07-30 17:59:43 +08:00
})
2026-08-06 13:56:04 +08:00
2026-07-30 17:59:43 +08:00
const form = reactive(emptyForm())
2026-08-06 13:56:04 +08:00
type WarehouseRequiredField = 'code' | 'name' | 'province' | 'countryCode' | 'timezone' | 'city' | 'postalCode' | 'address'
const requiredFieldMessages: Record<WarehouseRequiredField, string> = {
code: '请输入仓库编码',
name: '请输入仓库名称',
2026-08-06 13:56:04 +08:00
province: '请选择或输入州/省',
countryCode: '请选择国家或地区',
timezone: '请输入时区',
city: '请输入城市',
2026-08-06 13:56:04 +08:00
postalCode: '请输入邮编',
address: '请输入详细地址',
}
2026-08-04 11:14:34 +08:00
const formErrors = reactive<Record<WarehouseRequiredField, string>>({
2026-08-06 13:56:04 +08:00
code: '', name: '', province: '', countryCode: '', timezone: '', city: '', postalCode: '', address: '',
2026-08-04 11:14:34 +08:00
})
2026-08-06 13:56:04 +08:00
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')),
))
2026-07-30 17:59:43 +08:00
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'warehouse-editor',
label: editorLabel,
isOpen: editorOpen,
model: form,
requiredPermission: editorPermission,
})
2026-08-04 11:14:34 +08:00
const warehouseReady = computed(() => loaded.value && !loading.value)
2026-07-31 13:43:16 +08:00
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)
2026-08-10 16:24:54 +08:00
return [loadError.value.message, references.join(' · ')].filter(Boolean).join(' ')
2026-07-31 13:43:16 +08:00
})
2026-07-30 17:59:43 +08:00
watch(
2026-07-31 15:34:35 +08:00
() => access.can(editorPermission.value),
2026-07-30 17:59:43 +08:00
(canWrite) => {
if (canWrite || !editorOpen.value) return
editorOpen.value = false
Object.assign(form, emptyForm())
},
{ flush: 'sync' },
)
2026-08-06 13:56:04 +08:00
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')
})
2026-07-30 17:59:43 +08:00
2026-08-06 13:56:04 +08:00
function toRow(warehouse: PlatformWarehouseSummaryResource): WarehouseRow {
const address = warehouse.primaryAddress
const addressText = warehouse.addressSummary
|| [address?.district, address?.addressLine1, address?.addressLine2].filter(Boolean).join(' ')
2026-07-31 13:43:16 +08:00
return {
id: String(warehouse.id),
code: warehouse.warehouseCode,
name: warehouse.warehouseName,
2026-08-04 11:14:34 +08:00
warehouseType: warehouse.warehouseType,
countryCode: warehouse.countryCode,
timezone: warehouse.timezone,
2026-07-31 13:43:16 +08:00
province: address?.stateProvince || '',
city: address?.city || '',
2026-08-06 13:56:04 +08:00
address: addressText,
postalCode: address?.postalCode || '',
2026-07-31 13:43:16 +08:00
contactName: warehouse.contactName || '',
contactPhone: warehouse.contactPhone || '',
2026-08-06 13:56:04 +08:00
businessHours: '',
2026-07-31 13:43:16 +08:00
status: warehouse.status,
remark: warehouse.remark || '',
updatedAt: warehouse.updatedAt?.replace('T', ' ').slice(0, 16) || '',
2026-08-06 13:56:04 +08:00
detail: null,
2026-07-31 13:43:16 +08:00
}
}
function queryWarehouses(model: QueryModel) {
appliedFilters.value = {
keyword: String(model.keyword || ''),
2026-08-04 11:14:34 +08:00
warehouseType: (model.warehouseType || '') as WarehouseType | '',
status: (model.status || '') as Warehouse['status'] | '',
}
2026-08-06 13:56:04 +08:00
if (page.value === 1) void loadWarehouses()
else page.value = 1
2026-07-30 17:59:43 +08:00
}
function clearWarehouseFormErrors() {
for (const field of Object.keys(formErrors) as WarehouseRequiredField[]) formErrors[field] = ''
}
function clearFieldError(field: WarehouseRequiredField) {
if (formErrors[field]) formErrors[field] = ''
}
2026-08-06 13:56:04 +08:00
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() {
2026-08-06 13:56:04 +08:00
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 = ''
}
}
2026-08-04 11:14:34 +08:00
async function loadWarehouses() {
2026-07-31 13:43:16 +08:00
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
2026-08-06 13:56:04 +08:00
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,
2026-07-31 13:43:16 +08:00
})
2026-08-06 13:56:04 +08:00
if (sequence !== loadSequence) return
rows.value = result.records.map(toRow)
total.value = result.total
2026-08-04 11:14:34 +08:00
loaded.value = true
2026-07-31 13:43:16 +08:00
} catch (error) {
if (sequence !== loadSequence) return
loadError.value = getApiErrorDetails(error, '仓库数据加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
2026-08-04 11:14:34 +08:00
function requireWarehouseReady() {
if (!warehouseReady.value) {
ElMessage.warning('仓库数据尚未加载完成,请稍后重试')
2026-08-04 11:14:34 +08:00
return false
2026-07-31 13:43:16 +08:00
}
2026-08-04 11:14:34 +08:00
return true
2026-07-31 13:43:16 +08:00
}
2026-08-06 13:56:04 +08:00
function openCreate(type: WarehouseType) {
2026-08-04 11:14:34 +08:00
if (!requireWarehouseReady()) return
2026-08-06 13:56:04 +08:00
editorMode.value = 'CREATE'
createType.value = type
Object.assign(form, emptyForm(type))
clearWarehouseFormErrors()
2026-07-30 17:59:43 +08:00
captureBaseline()
editorOpen.value = true
}
2026-07-31 13:43:16 +08:00
async function ensureDetail(warehouse: WarehouseRow) {
if (warehouse.detail) return warehouse.detail
2026-08-04 11:14:34 +08:00
if (!requireWarehouseReady()) return null
2026-07-31 13:43:16 +08:00
try {
2026-08-06 13:56:04 +08:00
const detail = await getPlatformWarehouse(warehouse.id)
warehouse.detail = detail
2026-07-31 13:43:16 +08:00
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) {
2026-08-04 09:05:19 +08:00
editLoading.value = true
try {
const detail = await ensureDetail(warehouse)
2026-08-04 11:14:34 +08:00
if (!detail || !loaded.value) return
2026-08-04 09:05:19 +08:00
const address = detail.addresses[0]
2026-08-06 13:56:04 +08:00
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,
2026-08-04 11:14:34 +08:00
warehouseType: detail.warehouse.warehouseType,
countryCode: detail.warehouse.countryCode,
timezone: detail.warehouse.timezone,
2026-08-06 13:56:04 +08:00
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 || '',
2026-08-04 09:05:19 +08:00
})
clearWarehouseFormErrors()
captureBaseline()
editorOpen.value = true
} finally {
editLoading.value = false
}
2026-07-30 17:59:43 +08:00
}
2026-07-31 13:43:16 +08:00
function nullable(value: string) {
return value.trim() || null
}
2026-08-06 13:56:04 +08:00
function buildSaveRequest(): SavePlatformWarehouseRequest {
2026-07-31 13:43:16 +08:00
const existing = form.id ? rows.value.find((item) => item.id === form.id)?.detail : null
const warehouse = existing?.warehouse
const currentAddress = existing?.addresses[0]
2026-08-06 13:56:04 +08:00
const countryCode = isDomesticForm.value ? 'CN' : form.countryCode.trim().toUpperCase()
const timezone = isDomesticForm.value ? 'UTC+8' : form.timezone.trim()
2026-07-31 13:43:16 +08:00
const capabilities = (existing?.capabilities || []).map((capability) => ({
capabilityCode: capability.capabilityCode,
capabilityName: capability.capabilityName,
enabled: capability.enabled ?? true,
cutoffTime: capability.cutoffTime,
description: capability.capabilityCode === 'RECEIVING' ? nullable(form.businessHours) : capability.description,
}))
2026-08-06 13:56:04 +08:00
if (form.warehouseType !== 'FBA' && !capabilities.some((item) => item.capabilityCode === 'RECEIVING')) {
2026-07-31 13:43:16 +08:00
capabilities.push({
2026-08-06 13:56:04 +08:00
capabilityCode: 'RECEIVING', capabilityName: '收货服务', enabled: true, cutoffTime: null,
2026-07-31 13:43:16 +08:00
description: nullable(form.businessHours),
})
}
return {
warehouseCode: form.code.trim().toUpperCase(),
warehouseName: form.name.trim(),
2026-08-04 11:14:34 +08:00
warehouseType: form.warehouseType,
2026-08-06 13:56:04 +08:00
countryCode,
timezone,
2026-07-31 13:43:16 +08:00
contactName: nullable(form.contactName),
contactPhone: nullable(form.contactPhone),
contactEmail: warehouse?.contactEmail || null,
status: form.status,
remark: nullable(form.remark),
address: {
addressType: currentAddress?.addressType || 'OPERATING',
2026-08-06 13:56:04 +08:00
countryCode,
2026-07-31 13:43:16 +08:00
stateProvince: nullable(form.province),
city: form.city.trim(),
district: currentAddress?.district || null,
2026-08-06 13:56:04 +08:00
postalCode: nullable(form.postalCode),
2026-07-31 13:43:16 +08:00
addressLine1: form.address.trim(),
addressLine2: currentAddress?.addressLine2 || null,
latitude: currentAddress?.latitude || null,
longitude: currentAddress?.longitude || null,
},
capabilities,
}
}
async function saveWarehouse() {
2026-08-06 13:56:04 +08:00
if (!requireWarehouseReady() || !validateWarehouseForm()) return
2026-07-31 13:43:16 +08:00
saving.value = true
2026-07-30 17:59:43 +08:00
try {
2026-08-06 13:56:04 +08:00
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)
}
2026-07-30 17:59:43 +08:00
editorOpen.value = false
2026-08-06 13:56:04 +08:00
ElMessage.success(editorMode.value === 'EDIT' ? '仓库资料已更新' : `${warehouseTypeLabels[createType.value]}已创建`)
if (page.value === 1) await loadWarehouses()
else page.value = 1
2026-07-30 17:59:43 +08:00
} catch (error) {
2026-07-31 13:43:16 +08:00
const details = getApiErrorDetails(error, '仓库保存失败,请检查后重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
saving.value = false
2026-07-30 17:59:43 +08:00
}
}
2026-07-31 13:43:16 +08:00
async function toggleStatus(warehouse: WarehouseRow) {
2026-08-04 11:14:34 +08:00
if (!requireWarehouseReady()) return
2026-07-30 17:59:43 +08:00
const target = warehouse.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const targetText = target === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(
target === 'INACTIVE'
2026-08-04 11:14:34 +08:00
? `停用“${warehouse.name}”后,客户新建头程订单时将无法选择该公共仓库。`
2026-07-30 17:59:43 +08:00
: `确认重新启用“${warehouse.name}”供客户下单选择吗?`,
2026-08-04 11:14:34 +08:00
`${targetText}公共仓库`,
2026-07-30 17:59:43 +08:00
{ confirmButtonText: `确认${targetText}`, cancelButtonText: '取消', type: target === 'INACTIVE' ? 'warning' : 'info' },
)
2026-07-31 13:43:16 +08:00
statusLoadingIds.value = new Set(statusLoadingIds.value).add(warehouse.id)
2026-08-06 13:56:04 +08:00
await changePlatformWarehouseStatus(warehouse.id, target)
2026-07-31 13:43:16 +08:00
warehouse.status = target
if (warehouse.detail) warehouse.detail.warehouse.status = target
2026-07-30 17:59:43 +08:00
ElMessage.success(`仓库已${targetText}`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
2026-07-31 13:43:16 +08:00
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
2026-07-30 17:59:43 +08:00
}
}
2026-07-31 13:43:16 +08:00
2026-08-06 13:56:04 +08:00
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 || '-'
}
2026-08-04 11:14:34 +08:00
onMounted(() => void loadWarehouses())
2026-07-30 17:59:43 +08:00
</script>
<template>
2026-08-10 16:24:54 +08:00
<AdminPage as="div">
<ListPageLayout aria-label="仓库列表页面">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" preference-key="warehouses-query" @search="queryWarehouses" />
</template>
<template v-if="loadError" #state>
<LoadErrorState
title="仓库数据加载失败"
:message="loadErrorDescription"
:loading="loading"
retry-text="重新查询"
@retry="loadWarehouses"
/>
</template>
2026-07-31 13:43:16 +08:00
2026-08-10 16:24:54 +08:00
<section v-if="!loadError" class="surface-panel table-panel" aria-live="polite">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
2026-08-06 13:56:04 +08:00
:data="rows"
:columns="warehouseColumns"
2026-08-06 19:03:13 +08:00
preference-key="warehouses-table"
:loading="loading"
2026-08-06 13:56:04 +08:00
:total="total"
:page-sizes="[20, 50, 100]"
row-key="id"
2026-08-04 11:14:34 +08:00
aria-label="公共仓库列表"
:empty-text="loaded ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'"
>
2026-08-06 20:14:56 +08:00
<template #toolbar-actions>
<div v-if="access.can('platform:warehouse:create')" class="warehouse-primary-actions">
2026-08-10 16:24:54 +08:00
<el-button :disabled="!warehouseReady" @click="openImport"><FileUp :size="16" />导入仓库</el-button>
2026-08-06 20:14:56 +08:00
<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>
</template>
<template #actions="{ row }">
2026-08-06 13:56:04 +08:00
<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>
2026-08-04 11:14:34 +08:00
<template #warehouseType="{ row }"><el-tag effect="plain">{{ warehouseTypeLabels[(row as WarehouseRow).warehouseType] }}</el-tag></template>
2026-08-06 13:56:04 +08:00
<template #countryCode="{ row }">{{ formatCountryDisplay((row as WarehouseRow).countryCode) }}</template>
<template #city="{ row }">{{ [(row as WarehouseRow).province, (row as WarehouseRow).city].filter(Boolean).join(' · ') || '-' }}</template>
2026-08-06 13:56:04 +08:00
<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>
2026-08-06 13:56:04 +08:00
<template #pagination-left="{ total: tableTotal }">共 {{ tableTotal }} 个公共仓库</template>
</DataTable>
2026-08-10 16:24:54 +08:00
</section>
</ListPageLayout>
2026-07-30 17:59:43 +08:00
2026-08-12 16:58:52 +08:00
<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">
2026-08-10 16:24:54 +08:00
<FormSection title="基础信息">
2026-08-06 13:56:04 +08:00
<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>
2026-08-10 16:24:54 +08:00
</FormSection>
2026-08-06 13:56:04 +08:00
2026-08-10 16:24:54 +08:00
<FormSection title="地址信息">
2026-08-06 13:56:04 +08:00
<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>
2026-08-10 16:24:54 +08:00
</FormSection>
2026-08-06 13:56:04 +08:00
2026-08-10 16:24:54 +08:00
<FormSection v-if="!isFbaCreate" title="编码与状态">
2026-08-06 13:56:04 +08:00
<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>
2026-08-10 16:24:54 +08:00
</FormSection>
2026-08-06 13:56:04 +08:00
2026-08-10 16:24:54 +08:00
<FormSection v-if="!isFbaCreate" title="联系信息">
2026-08-06 13:56:04 +08:00
<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>
2026-08-10 16:24:54 +08:00
</FormSection>
2026-07-30 17:59:43 +08:00
</el-form>
2026-08-10 16:24:54 +08:00
<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>
2026-08-12 16:58:52 +08:00
</AppDialog>
2026-08-06 13:56:04 +08:00
<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">
2026-08-10 16:24:54 +08:00
<el-button><FileUp :size="16" />选择 XLSX 文件</el-button>
2026-08-06 13:56:04 +08:00
<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>
2026-08-10 16:24:54 +08:00
<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>
2026-08-06 13:56:04 +08:00
</el-dialog>
2026-08-10 16:24:54 +08:00
</AdminPage>
2026-07-30 17:59:43 +08:00
</template>
<style scoped>
2026-07-31 13:43:16 +08:00
.state-alert p { margin: 6px 0 10px; line-height: 1.55; }
2026-08-06 13:56:04 +08:00
.warehouse-heading { gap: 16px; }
.warehouse-primary-actions, .import-toolbar, .import-result-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
2026-07-30 17:59:43 +08:00
.warehouse-cell { display: flex; align-items: center; gap: 10px; }
2026-08-06 13:56:04 +08:00
.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); }
2026-07-30 17:59:43 +08:00
.warehouse-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
2026-07-31 13:43:16 +08:00
.warehouse-cell strong { overflow-wrap: anywhere; }
2026-08-06 13:56:04 +08:00
.warehouse-cell small, .warehouse-subline, .warehouse-address small { display: block; color: var(--cc-muted); font-size: 11px; }
2026-07-31 13:43:16 +08:00
.warehouse-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; }
2026-07-30 17:59:43 +08:00
.warehouse-address svg { flex: 0 0 15px; margin-top: 2px; color: var(--cc-muted); }
2026-08-06 13:56:04 +08:00
.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; }
2026-08-06 13:56:04 +08:00
.compact-grid { row-gap: 10px; }
2026-08-12 16:58:52 +08:00
: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; }
2026-08-06 13:56:04 +08:00
.warehouse-status-toggle :deep(.el-radio-button__inner) { min-width: 74px; }
2026-08-12 16:58:52 +08:00
@media (max-width: 720px) {
.drawer-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); }
}
2026-08-06 13:56:04 +08:00
.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; }
2026-08-06 13:56:04 +08:00
:global(.el-dropdown-menu__item) { gap: 8px; }
2026-07-31 13:43:16 +08:00
2026-08-03 11:45:45 +08:00
@media (max-width: 720px) {
2026-08-06 13:56:04 +08:00
.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); }
2026-08-03 11:45:45 +08:00
}
2026-08-06 13:56:04 +08:00
@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); }
}
2026-07-30 17:59:43 +08:00
</style>