style(admin): unify dialog layouts and dense readonly presentation

This commit is contained in:
2026-10-20 10:11:31 +08:00
parent 7ff7c9914f
commit 3267679758
214 changed files with 10931 additions and 2654 deletions
@@ -1,12 +1,14 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElDialog, ElFormItem, ElMessageBox, ElRadioButton, ElRadioGroup, ElSelect, ElUpload, type UploadFile } from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable from '@/shared/components/DataTable.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import {
changePlatformWarehouseStatus,
createFbaWarehouse,
createPlatformWarehouse,
getPlatformWarehouse,
@@ -188,8 +190,35 @@ function setField(selector: string, value: string) {
field!.dispatchEvent(new Event('input', { bubbles: true }))
}
function getImportDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '导入公共仓库')
expect(dialog).toBeDefined()
return dialog!
}
function getEditorDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') !== '导入公共仓库')
expect(dialog).toBeDefined()
return dialog!
}
function modalButton(text: string, selector = '.warehouse-import-dialog') {
const button = Array.from(document.querySelectorAll<HTMLButtonElement>(`${selector} .el-dialog__footer button`)).find(item => item.textContent?.trim() === text)
expect(button, text).toBeDefined()
return button!
}
function chooseImportFile(wrapper: Awaited<ReturnType<typeof mountView>>, file: File) {
const raw = Object.assign(file, { uid: 1 })
const upload: UploadFile = { name: file.name, status: 'ready', uid: 1, raw }
getImportDialog(wrapper).findComponent(ElUpload).props('onChange')?.(upload, [upload])
}
async function openImportDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
await wrapper.findAll('button').find(button => button.text().trim() === '导入仓库')!.trigger('click')
await flushPromises()
}
const validImport: WarehouseImportResult = { ...importPreview, totalRows: 3, validRows: 3, errorCount: 0, errors: [] }
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
afterEach(() => ElMessageBox.close())
describe('WarehousesView', () => {
it('uses bordered striped tables and semantic operation buttons', () => {
@@ -205,8 +234,11 @@ describe('WarehousesView', () => {
it('uses the prototype-sized centered warehouse editor with a fixed footer', () => {
expect(warehousesSource).toContain('<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))"')
expect(warehousesSource).not.toContain('<AppDrawer v-model="editorOpen"')
expect(warehousesSource).toContain(':global(.warehouse-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
expect(warehousesSource).toContain('.density-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); }')
expect(warehousesSource).toContain('fullscreenable')
expect(warehousesSource).toContain('max-height:min(800px, calc(100vh - 32px))')
expect(warehousesSource).not.toContain(':global(.warehouse-editor-dialog .el-dialog__body)')
expect(warehousesSource).toContain('density-table density-table--editable warehouse-editor-table')
expect(warehousesSource).toContain('label-position="left" label-width="104px"')
})
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
@@ -217,6 +249,7 @@ describe('WarehousesView', () => {
vi.mocked(createFbaWarehouse).mockReset().mockResolvedValue('19006')
vi.mocked(updatePlatformWarehouse).mockReset().mockResolvedValue(null)
vi.mocked(importWarehouses).mockReset().mockResolvedValue(importPreview)
vi.mocked(changePlatformWarehouseStatus).mockReset().mockResolvedValue(null)
})
it('loads a server-paged warehouse list with backend address summaries and no row detail fan-out', async () => {
@@ -237,7 +270,7 @@ describe('WarehousesView', () => {
expect(wrapper.find('.data-table').text()).not.toContain('Asia/Shanghai')
expect(wrapper.text()).toContain('共 1 个公共仓库')
expect(wrapper.find('.data-table').exists()).toBe(true)
const editButton = wrapper.findAll('.table-actions button').find((button) => button.text().trim() === '编辑')
const editButton = wrapper.findAll('.el-table__body .table-actions button').find((button) => button.text().trim() === '编辑')
await editButton?.trigger('click')
await flushPromises()
expect((wrapper.find('textarea[placeholder*="街道"]').element as HTMLTextAreaElement).value).toBe('真实物流园 1 号库')
@@ -327,9 +360,8 @@ describe('WarehousesView', () => {
expect(dialogText).not.toContain('收货时间')
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '美国 US')
expect(countrySelect).toBeDefined()
expect(countrySelect!.props('modelValue')).toBe('US')
expect(countrySelect!.props('disabled')).toBe(true)
expect(countrySelect).toBeUndefined()
expect(dialogText).toContain('美国 US')
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择州')
@@ -564,8 +596,8 @@ describe('WarehousesView', () => {
})
it('renders FBA import precheck totals and row-level errors without committing by default', async () => {
expect(warehousesSource).toContain('<el-table v-if="importResult.errors.length" :data="importResult.errors" max-height="260" size="small">')
expect(warehousesSource).not.toContain('importResult.errors" max-height="260" size="small" border stripe')
expect(warehousesSource).toContain('<el-table :data="importResult.errors" size="small">')
expect(warehousesSource).not.toContain('max-height="260"')
const wrapper = await mountView()
await wrapper.findAll('button').find((button) => button.text().trim() === '导入仓库')?.trigger('click')
@@ -589,4 +621,152 @@ describe('WarehousesView', () => {
expect(document.body.textContent).toContain('文件内编码重复')
expect(importWarehouses).toHaveBeenCalledTimes(1)
})
it('retains domestic draft values and the fixed defaults through fullscreen before saving', async () => {
const wrapper = await mountView()
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'DOMESTIC')
await flushPromises()
setField('input[placeholder="请输入仓库名称"]', '全屏草稿仓库')
setField('input[placeholder="例如 义乌市"]', '义乌市')
setField('textarea[placeholder*="街道"]', '完整地址')
await flushPromises()
document.querySelector<HTMLButtonElement>('.warehouse-editor-dialog button[aria-label="展开全屏"]')!.click()
await flushPromises()
expect(document.querySelector('.warehouse-editor-dialog.is-fullscreen')).not.toBeNull()
expect(document.querySelector<HTMLInputElement>('.warehouse-editor-dialog input[placeholder="请输入仓库名称"]')?.value).toBe('全屏草稿仓库')
document.querySelector<HTMLButtonElement>('.warehouse-editor-dialog button[aria-label="退出全屏"]')!.click()
await flushPromises()
modalButton('确认新增', '.warehouse-editor-dialog').click(); await flushPromises()
expect(createPlatformWarehouse).toHaveBeenCalledWith(expect.objectContaining({ warehouseName: '全屏草稿仓库', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', address: expect.objectContaining({ addressLine1: '完整地址', city: '义乌市' }) }))
})
it('uses readonly values for fixed domestic edit fields and identifies the warehouse by code', async () => {
const wrapper = await mountView()
await wrapper.findAll('.el-table__body .table-actions button').find(button => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
const editor = getEditorDialog(wrapper)
expect(editor.props('title')).toBe('编辑国内收货仓 · CN-YW-01')
expect(getPlatformWarehouse).toHaveBeenCalledWith(domesticWarehouse.id)
for (const label of ['国家或地区', '时区']) {
const item = editor.findAllComponents(ElFormItem).find(field => field.props('label') === label)!
expect(item.findComponent(ElSelect).exists()).toBe(false)
}
expect(document.querySelector('.warehouse-editor-dialog')?.textContent).toContain('中国 CN')
expect(document.querySelector('.warehouse-editor-dialog')?.textContent).toContain('Asia/Shanghai')
})
it('does not let an older edit request overwrite a newly opened create draft', async () => {
let finish!: (value: PlatformWarehouseDetailResource) => void
vi.mocked(getPlatformWarehouse).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
const wrapper = await mountView()
await wrapper.findAll('.el-table__body .table-actions button').find(button => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'FBA')
await flushPromises()
setField('input[placeholder="例如 LAX9"]', 'NEW2')
finish(detail); await flushPromises()
expect(getEditorDialog(wrapper).props('title')).toBe('新增FBA 地址')
expect(document.querySelector<HTMLInputElement>('input[placeholder="例如 LAX9"]')?.value).toBe('NEW2')
expect(document.querySelector('.warehouse-editor-dialog')?.textContent).not.toContain('义乌收货仓')
})
it('keeps import file, mode and preview through fullscreen and commits only after a successful precheck', async () => {
vi.mocked(importWarehouses).mockResolvedValueOnce(validImport).mockResolvedValueOnce({ ...validImport, committed: true })
const wrapper = await mountView(); await openImportDialog(wrapper)
const file = new File(['content'], 'warehouses.xlsx')
chooseImportFile(wrapper, file); await flushPromises()
expect(modalButton('确认导入').disabled).toBe(true)
expect(importWarehouses).not.toHaveBeenCalled()
modalButton('预检文件').click(); await flushPromises()
expect(importWarehouses).toHaveBeenCalledWith(file, true, 'CREATE_ONLY')
expect(document.querySelector('.warehouse-import-summary')?.textContent).toContain('总行数')
expect(modalButton('确认导入').disabled).toBe(false)
document.querySelector<HTMLButtonElement>('.warehouse-import-dialog button[aria-label="展开全屏"]')!.click()
await flushPromises()
expect(document.querySelector('.warehouse-import-dialog.is-fullscreen')).not.toBeNull()
expect(importWarehouses).toHaveBeenCalledTimes(1)
modalButton('确认导入').click(); await flushPromises()
expect(importWarehouses).toHaveBeenLastCalledWith(file, false, 'CREATE_ONLY')
expect(document.querySelector('.warehouse-import-dialog')?.textContent).toContain('导入已提交')
expect(modalButton('确认导入').disabled).toBe(true)
expect(getImportDialog(wrapper).props('modelValue')).toBe(true)
})
it.each(['extension', 'size'] as const)('invalidates the previous preview when a new file fails the %s check', async invalid => {
vi.mocked(importWarehouses).mockResolvedValue(validImport)
const wrapper = await mountView(); await openImportDialog(wrapper)
chooseImportFile(wrapper, new File(['content'], 'valid.xlsx')); await flushPromises()
modalButton('预检文件').click(); await flushPromises()
expect(modalButton('确认导入').disabled).toBe(false)
const file = new File(['content'], invalid === 'extension' ? 'bad.csv' : 'big.xlsx')
if (invalid === 'size') Object.defineProperty(file, 'size', { value: 5 * 1024 * 1024 + 1 })
chooseImportFile(wrapper, file); await flushPromises()
expect(document.querySelector('.warehouse-import-summary')).toBeNull()
expect(modalButton('确认导入').disabled).toBe(true)
expect(modalButton('预检文件').disabled).toBe(true)
expect(importWarehouses).toHaveBeenCalledTimes(1)
})
it('locks import closing, file changes and mode controls while the precheck is running', async () => {
let finish!: (value: WarehouseImportResult) => void
vi.mocked(importWarehouses).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
const wrapper = await mountView(); await openImportDialog(wrapper)
const file = new File(['content'], 'current.xlsx')
chooseImportFile(wrapper, file); await flushPromises()
modalButton('预检文件').click(); await flushPromises()
const dialog = getImportDialog(wrapper)
expect(dialog.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(dialog.findComponent(ElUpload).props('disabled')).toBe(true)
expect(dialog.findComponent(ElRadioGroup).props('disabled')).toBe(true)
expect(modalButton('关闭').disabled).toBe(true)
chooseImportFile(wrapper, new File(['other'], 'other.xlsx'))
finish(validImport); await flushPromises()
vi.mocked(importWarehouses).mockResolvedValueOnce({ ...validImport, committed: true })
modalButton('确认导入').click(); await flushPromises()
expect(importWarehouses).toHaveBeenLastCalledWith(file, false, 'CREATE_ONLY')
})
it('invalidates a precheck when changing import mode and retains the update-permission gate', async () => {
vi.mocked(importWarehouses).mockResolvedValue(validImport)
const wrapper = await mountView(); await openImportDialog(wrapper)
const dialog = getImportDialog(wrapper)
dialog.findComponent(ElRadioGroup).vm.$emit('update:modelValue', 'UPSERT')
chooseImportFile(wrapper, new File(['content'], 'upsert.xlsx')); await flushPromises()
modalButton('预检文件').click(); await flushPromises()
expect(importWarehouses).toHaveBeenLastCalledWith(expect.any(File), true, 'UPSERT')
expect(modalButton('确认导入').disabled).toBe(false)
useAuthStore().permissions = ['platform:warehouse:list', 'platform:warehouse:create']
useAccessPreviewStore().initializeAfterLogin(); await flushPromises()
expect(dialog.findAllComponents(ElRadioButton).find(button => button.props('value') === 'UPSERT')!.props('disabled')).toBe(true)
expect(modalButton('确认导入').disabled).toBe(true)
dialog.findComponent(ElRadioGroup).vm.$emit('update:modelValue', 'CREATE_ONLY'); await flushPromises()
expect(document.querySelector('.warehouse-import-summary')).toBeNull()
expect(modalButton('确认导入').disabled).toBe(true)
expect(importWarehouses).toHaveBeenCalledTimes(1)
})
it('retains an import draft after failure so the same file can be retried', async () => {
vi.mocked(importWarehouses).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(validImport)
const wrapper = await mountView(); await openImportDialog(wrapper)
const file = new File(['content'], 'retry.xlsx')
chooseImportFile(wrapper, file); await flushPromises()
modalButton('预检文件').click(); await flushPromises()
expect(getImportDialog(wrapper).props('modelValue')).toBe(true)
expect(modalButton('预检文件').disabled).toBe(false)
modalButton('预检文件').click(); await flushPromises()
expect(vi.mocked(importWarehouses).mock.calls).toEqual([[file, true, 'CREATE_ONLY'], [file, true, 'CREATE_ONLY']])
expect(modalButton('确认导入').disabled).toBe(false)
})
it.each(['ACTIVE', 'INACTIVE'] as const)('keeps warehouse %s confirmation and the original status payload', async status => {
const id = '9007199254740993123'
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [{ ...domesticWarehouse, id, status }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = await mountView()
const verb = status === 'ACTIVE' ? '停用' : '启用'
await wrapper.findAll('.el-table__body .table-actions button').find(button => button.text().trim() === verb)!.trigger('click'); await flushPromises()
expect(document.querySelector('.el-message-box__title')?.textContent).toBe(`${verb}公共仓库 · CN-YW-01`)
expect(changePlatformWarehouseStatus).not.toHaveBeenCalled()
document.querySelector<HTMLButtonElement>('.el-message-box .el-button--primary')!.click(); await flushPromises()
expect(changePlatformWarehouseStatus).toHaveBeenCalledWith(id, status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE')
})
})
+80 -92
View File
@@ -6,7 +6,6 @@ import { ChevronDown, Download, FileSpreadsheet, FileUp, MapPin, Plus, Save } fr
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'
@@ -161,6 +160,7 @@ const queryFields: QueryField[] = [
},
]
let loadSequence = 0
let editorLoadSequence = 0
interface WarehouseRow extends Warehouse {
warehouseType: WarehouseType
@@ -238,8 +238,12 @@ const formErrors = reactive<Record<WarehouseRequiredField, string>>({
code: '', name: '', province: '', countryCode: '', timezone: '', city: '', postalCode: '', address: '',
})
const editorLabel = computed(() => editorMode.value === 'EDIT'
? `编辑${warehouseTypeLabels[form.warehouseType]}`
? warehouseDialogTitle(`编辑${warehouseTypeLabels[form.warehouseType]}`, form.code)
: `新增${warehouseTypeLabels[createType.value]}`)
function warehouseDialogTitle(action: string, code: string) {
return code.trim() ? `${action} · ${code.trim()}` : action
}
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')
@@ -434,6 +438,8 @@ function requireWarehouseReady() {
function openCreate(type: WarehouseType) {
if (!requireWarehouseReady()) return
editorLoadSequence += 1
editLoading.value = false
editorMode.value = 'CREATE'
createType.value = type
Object.assign(form, emptyForm(type))
@@ -457,10 +463,12 @@ async function ensureDetail(warehouse: WarehouseRow) {
}
async function openEdit(warehouse: WarehouseRow) {
const sequence = ++editorLoadSequence
const listSequence = loadSequence
editLoading.value = true
try {
const detail = await ensureDetail(warehouse)
if (!detail || !loaded.value) return
if (sequence !== editorLoadSequence || listSequence !== loadSequence || !detail || !loaded.value) return
const address = detail.addresses[0]
const receiving = detail.capabilities.find((item) => item.capabilityCode === 'RECEIVING')
editorMode.value = 'EDIT'
@@ -486,7 +494,7 @@ async function openEdit(warehouse: WarehouseRow) {
captureBaseline()
editorOpen.value = true
} finally {
editLoading.value = false
if (sequence === editorLoadSequence) editLoading.value = false
}
}
@@ -585,7 +593,7 @@ async function toggleStatus(warehouse: WarehouseRow) {
target === 'INACTIVE'
? `停用“${warehouse.name}”后,客户新建头程订单时将无法选择该公共仓库。`
: `确认重新启用“${warehouse.name}”供客户下单选择吗?`,
`${targetText}公共仓库`,
warehouseDialogTitle(`${targetText}公共仓库`, warehouse.code),
{ customClass: 'admin-density-confirm', confirmButtonText: `确认${targetText}`, cancelButtonText: '取消', type: target === 'INACTIVE' ? 'warning' : 'info' },
)
statusLoadingIds.value = new Set(statusLoadingIds.value).add(warehouse.id)
@@ -612,6 +620,8 @@ function openImport() {
}
function selectImportFile(uploadFile: UploadFile) {
if (importing.value) return
importResult.value = null
const file = uploadFile.raw ?? null
if (!file) return
if (!file.name.toLowerCase().endsWith('.xlsx')) {
@@ -625,10 +635,10 @@ function selectImportFile(uploadFile: UploadFile) {
return
}
importFile.value = file
importResult.value = null
}
function removeImportFile() {
if (importing.value) return
importFile.value = null
importResult.value = null
}
@@ -655,6 +665,7 @@ async function downloadTemplate() {
}
async function runImport(dryRun: boolean) {
if (importing.value || (!dryRun && !canCommitImport.value)) return
if (!importFile.value) {
ElMessage.warning('请先选择 XLSX 文件')
return
@@ -761,103 +772,90 @@ onMounted(() => void loadWarehouses())
</DataTable></section>
</ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="warehouse-editor-dialog admin-density-dialog" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close>
<el-form :model="form" label-position="top" class="density-form" :disabled="saving">
<FormSection class="density-form-section" 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>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="warehouse-editor-dialog admin-density-dialog" fullscreenable :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close>
<el-form :model="form" label-position="left" label-width="104px" class="density-form density-dialog-sections" :disabled="saving">
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">基础信息</h3>
<table class="density-table density-table--editable warehouse-editor-table" aria-label="仓库基础信息"><tbody>
<template v-if="isFbaCreate"><tr><td><el-form-item 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></td><td><el-form-item label="国家或地区" required>
<span>{{ formatCountryDisplay(form.countryCode) }}</span>
</el-form-item></td></tr></template>
<template v-else><tr v-if="editorMode === 'EDIT'"><td><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></td><td><el-form-item 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></td></tr><tr v-else><td colspan="2"><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></td></tr><tr v-if="!isDomesticCreate"><td><el-form-item label="国家或地区" required :class="{ 'has-field-error': formErrors.countryCode }">
<span v-if="isDomesticForm">{{ formatCountryDisplay(form.countryCode) }}</span>
<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="Asia/Shanghai"><el-option label="Asia/Shanghai" value="Asia/Shanghai" /></el-select>
</el-form-item></td><td><el-form-item label="时区" required :class="{ 'has-field-error': formErrors.timezone }">
<span v-if="isDomesticForm">{{ form.timezone }}</span>
<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 class="density-form-section" title="地址信息">
<div class="form-grid two-cols">
<el-form-item :label="addressRegionLabel" :required="isFbaCreate || usesUsStateSelect" :class="{ 'has-field-error': formErrors.province }">
</el-form-item></td></tr></template>
<tr v-if="activeWarehouseType === 'FBA'"><td colspan="2"><el-form-item 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></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">地址信息</h3>
<table class="density-table density-table--editable warehouse-editor-table" aria-label="仓库地址信息"><tbody>
<tr><td><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 class="density-form-section" 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 class="density-form-section" 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-item></td><td><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></td></tr>
<tr><td colspan="2"><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></td></tr>
<tr><td colspan="2"><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></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" v-if="!isFbaCreate"><h3 class="density-dialog-section__title">编码与状态</h3>
<table class="density-table density-table--editable warehouse-editor-table" aria-label="仓库编码与状态"><tbody>
<tr><td><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></td><td><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></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" v-if="!isFbaCreate"><h3 class="density-dialog-section__title">联系信息</h3>
<table class="density-table density-table--editable warehouse-editor-table" aria-label="仓库联系信息"><tbody>
<tr><td><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="选填" /></el-form-item></td><td><el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" inputmode="numeric" placeholder="选填" @input="form.contactPhone = digitsOnly($event)" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="收货时间"><el-time-picker v-model="form.receivingCutoffTime" value-format="HH:mm:ss" format="HH:mm" placeholder="请选择收货截止时间" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit placeholder="选填" /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveWarehouse"><Save :size="16" />{{ editorMode === 'EDIT' ? '保存修改' : '确认新增' }}</el-button></div></template>
<template #footer><div class="density-actions density-actions--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 class="admin-density-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">
<AppDialog class="warehouse-import-dialog admin-density-dialog" v-model="importOpen" fullscreenable :confirm-loading="importing" :show-confirm="false" title="导入公共仓库" width="min(760px, calc(100vw - 32px))" :close-on-click-modal="!importing" :close-on-press-escape="!importing">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">文件与导入模式</h3>
<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-upload :disabled="importing" 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 density-detail-overview">
<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">
</section>
<section v-if="importResult" class="density-dialog-section" data-tone="summary" role="status">
<h3 class="density-dialog-section__title warehouse-import-heading">导入结果<el-tag :type="importResult.committed ? 'success' : importResult.errorCount ? 'danger' : 'warning'">{{ importResult.committed ? '导入已提交' : (importResult.errorCount ? '预检未通过,未写入数据' : '预检通过,尚未写入数据') }}</el-tag></h3>
<table class="density-table warehouse-import-summary" aria-label="仓库导入统计"><tbody>
<tr><th scope="row">总行数</th><td>{{ importResult.totalRows }}</td><th scope="row">有效</th><td>{{ importResult.validRows }}</td><th scope="row">新增</th><td>{{ importResult.createCount }}</td></tr>
<tr><th scope="row">更新</th><td>{{ importResult.updateCount }}</td><th scope="row">无变化</th><td>{{ importResult.unchangedCount }}</td><th scope="row">错误</th><td><el-tag v-if="importResult.errorCount" type="danger">{{ importResult.errorCount }}</el-tag><span v-else>{{ importResult.errorCount }}</span></td></tr>
</tbody></table>
</section>
<section v-if="importResult?.errors.length" class="density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title warehouse-import-heading">逐行校验结果<el-button size="small" @click="downloadImportErrors"><Download :size="15" />下载错误清单</el-button></h3>
<el-table :data="importResult.errors" 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>
</section>
</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>
<template #footer><div class="density-actions density-actions--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></div></template>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.state-alert p { margin: var(--admin-space-2) 0 var(--admin-space-4); line-height: 1.55; }
.warehouse-heading { gap: var(--admin-space-6); }
.warehouse-primary-actions, .import-toolbar, .import-result-heading { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4); }
.warehouse-primary-actions, .import-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4); }
.warehouse-cell { display: flex; align-items: center; gap: var(--admin-space-4); }
.warehouse-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: var(--admin-radius); background: #eef3fa; color: var(--cc-primary); }
.warehouse-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
@@ -867,18 +865,9 @@ onMounted(() => void loadWarehouses())
.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: var(--admin-space-2) 0 0; color: var(--el-color-danger); font-size: var(--admin-font-caption); line-height: 1.35; overflow-wrap: anywhere; }
.compact-grid { row-gap: var(--admin-space-4); }
: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) {
.density-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); }
}
.import-content { display: grid; gap: var(--admin-space-7); }
.import-toolbar { justify-content: space-between; }
.import-result { display: grid; gap: var(--admin-space-5); padding-top: var(--admin-space-6); border-top: 1px solid var(--cc-border); }
.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; }
@@ -891,17 +880,16 @@ onMounted(() => void loadWarehouses())
.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); }
}
:global(.warehouse-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height:min(800px, calc(100vh - 32px)); }
.warehouse-editor-table { table-layout:fixed; }
.warehouse-editor-table td { width:50%; }
.warehouse-import-heading { justify-content:space-between; }
.import-toolbar { margin-bottom:var(--admin-space-4); }
@media (max-width:600px) { .warehouse-editor-table tbody { display:block; } .warehouse-editor-table tr { display:grid; grid-template-columns:minmax(0,1fr); } .warehouse-editor-table td { width:auto; } }
</style>