style(admin): unify dialog layouts and dense readonly presentation
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createRequire } from 'node:module'
|
||||
import DomesticContainersView from './DomesticContainersView.vue'
|
||||
import * as api from '../api/domesticContainers'
|
||||
import type { InventoryRecord } from '../types'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
// Use Node's supported require export so the validator retains its constructor interop.
|
||||
const ElementPlus = (createRequire(import.meta.url)('element-plus') as typeof import('element-plus')).default
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/domestic-containers/pages/DomesticContainersView.vue'), 'utf8')
|
||||
const summaryStyles = readFileSync(resolve(process.cwd(), 'src/modules/domestic-containers/pages/domesticContainersSummary.css'), 'utf8')
|
||||
describe('DomesticContainersView', () => {
|
||||
@@ -22,3 +29,86 @@ describe('DomesticContainersView', () => {
|
||||
it('matches backend numeric and container-number input boundaries before submit', () => { expect(source).toContain("pattern: /^[A-Za-z0-9-]{4,32}$/"); expect(source).toContain(':max="999999999"'); expect(source).toContain(':max="999999999999.9999"') })
|
||||
it('requires a refreshed inventory version for completed outbound submission', () => { expect(source).toContain("outboundForm.inventoryVersion == null"); expect(source).toContain('库存版本未就绪,请重新选择集装箱') })
|
||||
})
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([{ id: 'warehouse-1', warehouseCode: 'WH-01', warehouseName: '测试仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' }]) }))
|
||||
vi.mock('../api/domesticContainers', () => ({ createDomesticContainerInbound: vi.fn(), createDomesticContainerOutbound: vi.fn(), getDomesticContainerInventory: vi.fn(), listDomesticContainerInbounds: vi.fn(), listDomesticContainerInventories: vi.fn(), listDomesticContainerOutbounds: vi.fn() }))
|
||||
const inventory: InventoryRecord = { id: 'inventory-1', warehouseId: 'warehouse-1', warehouseName: '测试仓', containerNo: 'MSCU1234567', cargoName: '测试货物', availableBoxes: 8, inboundBoxes: 10, outboundBoxes: 2, inboundVolumeCbm: 2.5, inventoryStatus: 'PARTIALLY_OUTBOUND', version: 3, updatedAt: '2026-09-30T10:00:00' }
|
||||
let mounted: VueWrapper | undefined
|
||||
async function openCreate(label: string) {
|
||||
const view = mount(DomesticContainersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
mounted = view
|
||||
await flushPromises()
|
||||
if (label === '新建出库单') await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
|
||||
await flushPromises()
|
||||
await view.findAll('button').find(button => button.text() === label)!.trigger('click')
|
||||
await flushPromises()
|
||||
const form = view.findComponent({ name: 'ElForm' })
|
||||
return { view, form }
|
||||
}
|
||||
async function submitCreate() {
|
||||
document.querySelector<HTMLButtonElement>('.el-dialog__footer .el-button--primary')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
describe('DomesticContainersView create workflows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
vi.mocked(api.listDomesticContainerInbounds).mockResolvedValue(empty)
|
||||
vi.mocked(api.listDomesticContainerOutbounds).mockResolvedValue(empty)
|
||||
vi.mocked(api.listDomesticContainerInventories).mockResolvedValue({ ...empty, records: [inventory], summary: { inboundBoxes: 10, outboundBoxes: 2, availableBoxes: 8, inboundVolumeCbm: 2.5 } })
|
||||
vi.mocked(api.getDomesticContainerInventory).mockResolvedValue({ inventory: { ...inventory, availableBoxes: 6, version: 9 }, inboundOrderCount: 1, outboundOrderCount: 1, transactions: [] })
|
||||
})
|
||||
afterEach(() => { mounted?.unmount(); mounted = undefined; document.body.innerHTML = '' })
|
||||
|
||||
it('keeps validation, normalized input and the idempotency key when retrying an inbound', async () => {
|
||||
vi.mocked(api.createDomesticContainerInbound).mockRejectedValueOnce(new Error('临时失败')).mockResolvedValueOnce({ id: 'order-internal', orderNo: 'ZGRK-01', replayed: false })
|
||||
const { form } = await openCreate('新建入库单')
|
||||
await submitCreate()
|
||||
expect(api.createDomesticContainerInbound).not.toHaveBeenCalled()
|
||||
const fields = form.findAllComponents({ name: 'ElFormItem' })
|
||||
const warehouse = fields.find(item => item.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' })
|
||||
warehouse.vm.$emit('update:modelValue', 'warehouse-1')
|
||||
await fields.find(item => item.props('prop') === 'cargoName')!.find('input').setValue(' 测试货物 ')
|
||||
await fields.find(item => item.props('prop') === 'containerNo')!.find('input').setValue('mscu1234567')
|
||||
fields.find(item => item.props('prop') === 'boxCount')!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 4)
|
||||
fields.find(item => item.props('prop') === 'volumeCbm')!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1.2345)
|
||||
await flushPromises()
|
||||
await submitCreate()
|
||||
expect(document.body.textContent).toContain('整柜单据创建失败')
|
||||
await submitCreate()
|
||||
const calls = vi.mocked(api.createDomesticContainerInbound).mock.calls
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0][0]).toEqual(calls[1][0])
|
||||
expect(calls[1][0]).toMatchObject({ warehouseId: 'warehouse-1', cargoName: '测试货物', containerNo: 'MSCU1234567', boxCount: 4, volumeCbm: 1.2345, status: 'COMPLETED', idempotencyKey: expect.any(String) })
|
||||
})
|
||||
|
||||
it('shows inventory facts as text and submits the refreshed version only within available stock', async () => {
|
||||
vi.mocked(api.createDomesticContainerOutbound).mockResolvedValue({ id: 'order-internal', orderNo: 'ZGCK-01', replayed: false })
|
||||
const { form } = await openCreate('新建出库单')
|
||||
const fields = form.findAllComponents({ name: 'ElFormItem' })
|
||||
const warehouse = fields.find(item => item.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' })
|
||||
warehouse.vm.$emit('update:modelValue', 'warehouse-1')
|
||||
warehouse.vm.$emit('change', 'warehouse-1')
|
||||
await flushPromises()
|
||||
const selection = fields.find(item => item.props('prop') === 'containerNo')!.findComponent({ name: 'ElSelect' })
|
||||
selection.vm.$emit('update:modelValue', 'inventory-1')
|
||||
selection.vm.$emit('change', 'inventory-1')
|
||||
await flushPromises()
|
||||
const cargo = fields.find(item => item.props('label') === '货物名称')!
|
||||
expect(cargo.text()).toContain('测试货物')
|
||||
expect(cargo.find('input').exists()).toBe(false)
|
||||
expect(fields.find(item => item.props('label') === '可用库存')!.text()).toContain('6 箱')
|
||||
const quantity = fields.find(item => item.props('prop') === 'boxCount')!.findComponent({ name: 'ElInputNumber' })
|
||||
quantity.vm.$emit('update:modelValue', 7)
|
||||
await flushPromises()
|
||||
await submitCreate()
|
||||
expect(api.createDomesticContainerOutbound).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('库存不足,当前可用 6 箱')
|
||||
quantity.vm.$emit('update:modelValue', 5)
|
||||
await flushPromises()
|
||||
await submitCreate()
|
||||
expect(api.createDomesticContainerOutbound).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ warehouseId: 'warehouse-1', containerNo: 'MSCU1234567', boxCount: 5, inventoryVersion: 9, idempotencyKey: expect.any(String) }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,6 +80,45 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||
<template #before-results><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div></template>
|
||||
<template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
|
||||
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit">
|
||||
<LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState>
|
||||
<el-form ref="formRef" :model="currentForm" :rules="rules" label-position="left" label-width="100px" class="density-form">
|
||||
<table class="density-table density-table--editable container-entry-table" :aria-label="dialogType === 'inbound' ? '整柜入库资料' : '整柜出库资料'">
|
||||
<tbody>
|
||||
<template v-if="dialogType === 'inbound'">
|
||||
<tr>
|
||||
<td><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></td>
|
||||
<td><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></td>
|
||||
<td><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></td>
|
||||
<td><el-form-item label="入库时间" prop="operationTime"><el-date-picker v-model="inboundForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr>
|
||||
<td><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></td>
|
||||
<td><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><el-form-item label="货物名称"><span>{{ outboundForm.cargoName || '—' }}</span></el-form-item></td>
|
||||
<td><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><el-form-item label="可用库存"><span>{{ outboundForm.availableBoxes }} 箱</span></el-form-item></td>
|
||||
<td><el-form-item label="出库时间" prop="operationTime"><el-date-picker v-model="outboundForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></td>
|
||||
</tr>
|
||||
<tr v-if="candidateError"><td colspan="2"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></td></tr>
|
||||
</template>
|
||||
<tr><td colspan="2"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</el-form>
|
||||
</AppDialog></AdminPage></template>
|
||||
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.container-entry-table{table-layout:fixed}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
||||
|
||||
Reference in New Issue
Block a user