style(admin): unify dialog layouts and dense readonly presentation
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import type { LoadingAssociationSummary, OverseasInboundCodeRelationship, PlatformInboundCodeAssociation } from '@/shared/api/inboundCodeRelations'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
import LineClampText from './LineClampText.vue'
|
||||
import LoadErrorState from './LoadErrorState.vue'
|
||||
import InboundCodeRelationshipDialog from './InboundCodeRelationshipDialog.vue'
|
||||
|
||||
const api = vi.hoisted(() => ({ base: vi.fn(), loading: vi.fn(), overseas: vi.fn() }))
|
||||
vi.mock('@/shared/api/inboundCodeRelations', () => ({ getPlatformInboundCodeAssociation: api.base, getLoadingAssociationSummary: api.loading, getOverseasInboundCodeRelationship: api.overseas }))
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
function base(): PlatformInboundCodeAssociation { return { inboundCode: 'IN-001', orderNo: 'ORDER-001', orderId: '9007199254740993', customerId: '9007199254740994', customerName: '完整客户名称'.repeat(15), orderStatus: 'APPROVED', destinationType: 'FBA', destinationCountryCode: 'US', destinationWarehouseName: '目的仓', destinationWarehouseCode: 'ABE3' } }
|
||||
function loading(): LoadingAssociationSummary { return { inboundId: '9007199254740995', inboundCode: 'IN-001', orderNo: 'ORDER-001', loadingPlanId: '9007199254740996', loadingPlanNo: 'PLAN-001', containerBookingId: '9007199254740997', cabinetNo: 'CAB-001', containerNo: null, loadingStatus: 'LOADING', pdaTaskStatus: 'IN_PROGRESS', coLoadGroupNo: 'GROUP-001', items: [] } }
|
||||
function overseas(): OverseasInboundCodeRelationship { return { inboundCode: 'IN-001', found: true, customer: { customerId: '9007199254740994', customerName: '客户一' }, overseasWarehouse: { warehouseId: '9007199254740998', warehouseName: '海外仓一', warehouseCode: 'LAX' }, overseasReceivingStatus: 'RECEIVING', taillegOrders: [{ orderNo: 'TAIL-001', orderStatus: 'PENDING' }, { orderNo: 'TAIL-002', orderStatus: 'READY' }], inboundPlans: [{ planNo: 'INBOUND-PLAN-001', planStatus: 'READY', receivingStatus: 'WAITING' }], palletCodes: ['PALLET-001', 'PALLET-002'], inventory: [{ skuCode: 'SKU-001', productNameZh: '完整中文品名'.repeat(15), packageCount: 0 }] } }
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
api.base.mockImplementation(async () => base()); api.loading.mockImplementation(async () => loading()); api.overseas.mockImplementation(async () => overseas())
|
||||
})
|
||||
async function open(props: Partial<InstanceType<typeof InboundCodeRelationshipDialog>['$props']> = {}) { const wrapper = mount(InboundCodeRelationshipDialog, { props: { dense: true, modelValue: true, inboundCode: ' IN-001 ', canReadBase: true, canReadLoading: true, canReadOverseas: true, ...props }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
|
||||
|
||||
describe('native relationship dialog profiles', () => {
|
||||
it('keeps every business field and complete long values in the shared dense sections and fullscreen', async () => {
|
||||
const { wrapper, dialog } = await open()
|
||||
expect(dialog.props('title')).toBe('入仓码关联数据 · IN-001'); expect(dialog.props('width')).toBe('min(1060px, 94vw)')
|
||||
const sections = wrapper.findAll('.density-dialog-section'); expect(sections).toHaveLength(3)
|
||||
expect(sections.map(section => section.attributes('data-tone'))).toEqual(['source', 'source', 'summary'])
|
||||
expect(sections[0]!.findAll('dt')).toHaveLength(6); expect(sections[1]!.findAll('dt')).toHaveLength(6); expect(sections[2]!.findAll('dt')).toHaveLength(5)
|
||||
const contents = wrapper.findAllComponents(LineClampText).map(item => item.props('content'))
|
||||
for (const value of [base().customerName, '目的仓(ABE3)', 'CAB-001', '海外仓一(LAX)', 'TAIL-001(PENDING)、TAIL-002(READY)', 'INBOUND-PLAN-001(READY) · WAITING', 'PALLET-001、PALLET-002', '完整中文品名'.repeat(15)]) expect(contents).toContain(value)
|
||||
expect(wrapper.text()).not.toMatch(/900719925474099[3-8]/)
|
||||
expect(wrapper.findAll('input, textarea, select')).toHaveLength(0)
|
||||
const stock = sections[2]!.findComponent({ name: 'ElTable' }); expect(stock.props('data')).toEqual(overseas().inventory)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect(wrapper.findAllComponents(LineClampText).map(item => item.props('content'))).toContain(base().customerName)
|
||||
})
|
||||
it('preserves the default profile without coloured sections, custom clamps or fullscreen controls', async () => {
|
||||
const { wrapper, dialog } = await open({ dense: false })
|
||||
expect(dialog.props('fullscreenable')).toBe(false)
|
||||
expect(wrapper.find('.admin-density-dialog').exists()).toBe(false); expect(wrapper.findAll('.density-dialog-section')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.relationship-section')).toHaveLength(3); expect(wrapper.findAll('.compact-list')).toHaveLength(3)
|
||||
expect(wrapper.findAllComponents(LineClampText)).toHaveLength(0)
|
||||
expect(wrapper.get('dd[title="IN-001"]').text()).toBe('IN-001')
|
||||
expect(wrapper.text()).toContain('TAIL-001(PENDING)、TAIL-002(READY)')
|
||||
expect(dialog.find('button[aria-label="展开全屏"]').exists()).toBe(false)
|
||||
})
|
||||
it.each(['base', 'loading', 'overseas'] as const)('reads only the allowed %s scope using the business code', async scope => {
|
||||
const { wrapper } = await open({ canReadBase: scope === 'base', canReadLoading: scope === 'loading', canReadOverseas: scope === 'overseas' })
|
||||
for (const [name, call] of Object.entries(api)) if (name === scope) expect(call).toHaveBeenCalledExactlyOnceWith('IN-001'); else expect(call).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('.relationship-section')).toHaveLength(1)
|
||||
})
|
||||
it('keeps an isolated error diagnostic and reloads all currently allowed scopes on retry', async () => {
|
||||
api.overseas.mockRejectedValueOnce(new ApiResponseError('海外失败', 'A0503', 503, null, 'relationship-overseas-request'))
|
||||
const { wrapper } = await open()
|
||||
expect(wrapper.text()).toContain('relationship-overseas-request'); expect(wrapper.text()).toContain('ORDER-001'); expect(wrapper.text()).toContain('PLAN-001')
|
||||
const error = wrapper.findComponent(LoadErrorState); expect(error.props('title')).toBe('海外关联加载失败')
|
||||
await error.get('.load-error-state__actions button').trigger('click'); await flushPromises()
|
||||
for (const call of Object.values(api)) expect(call).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('海外仓一'); expect(wrapper.findComponent(LoadErrorState).exists()).toBe(false)
|
||||
})
|
||||
it('clears a pending loading state when the code becomes empty and rejects its late result', async () => {
|
||||
let resolve!: (value: PlatformInboundCodeAssociation) => void
|
||||
api.base.mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||||
const { wrapper, dialog } = await open({ canReadLoading: false, canReadOverseas: false })
|
||||
expect(dialog.props('loading')).toBe(true)
|
||||
await wrapper.setProps({ inboundCode: ' ' }); expect(dialog.props('loading')).toBe(false)
|
||||
resolve(base()); await flushPromises()
|
||||
expect(wrapper.text()).toContain('缺少入仓码'); expect(wrapper.text()).not.toContain('ORDER-001'); expect(api.base).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('clears pending loading on permission loss without keeping data from the old scope', async () => {
|
||||
let resolve!: (value: PlatformInboundCodeAssociation) => void
|
||||
api.base.mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||||
const { wrapper, dialog } = await open({ canReadLoading: false, canReadOverseas: false })
|
||||
await wrapper.setProps({ canReadBase: false }); expect(dialog.props('loading')).toBe(false)
|
||||
resolve(base()); await flushPromises()
|
||||
expect(wrapper.text()).toContain('当前账号无入仓关联数据查看权限'); expect(wrapper.findAll('.relationship-section')).toHaveLength(0)
|
||||
expect(api.loading).not.toHaveBeenCalled(); expect(api.overseas).not.toHaveBeenCalled()
|
||||
})
|
||||
it('allows closing while reading and isolates an old response after reopening with another code', async () => {
|
||||
let resolve!: (value: PlatformInboundCodeAssociation) => void
|
||||
api.base.mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||||
const wrapper = mount(InboundCodeRelationshipDialog, { props: { dense: true, modelValue: true, inboundCode: ' IN-001 ', canReadBase: true, canReadLoading: false, canReadOverseas: false }, global: { plugins: [ElementPlus], stubs: { transition: false } } })
|
||||
await flushPromises(); const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(true)
|
||||
await dialog.get('.el-dialog__headerbtn').trigger('click'); await flushPromises()
|
||||
await vi.waitFor(() => expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]))
|
||||
await wrapper.setProps({ modelValue: false }); expect(dialog.props('loading')).toBe(false)
|
||||
api.base.mockResolvedValueOnce({ ...base(), inboundCode: 'IN-002', orderNo: 'ORDER-002' })
|
||||
await wrapper.setProps({ inboundCode: 'IN-002', modelValue: true }); await flushPromises()
|
||||
resolve(base()); await flushPromises()
|
||||
expect(wrapper.text()).toContain('ORDER-002'); expect(wrapper.text()).not.toContain('ORDER-001')
|
||||
expect(api.base.mock.calls).toEqual([['IN-001'], ['IN-002']])
|
||||
})
|
||||
it('invalidates a pending response when the consumer unmounts the dialog', async () => {
|
||||
let resolve!: (value: PlatformInboundCodeAssociation) => void
|
||||
api.base.mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||||
const { wrapper } = await open({ canReadLoading: false, canReadOverseas: false })
|
||||
const state = wrapper.vm as unknown as { baseAssociation: PlatformInboundCodeAssociation | null; loading: boolean }
|
||||
wrapper.unmount(); resolve(base()); await flushPromises()
|
||||
expect(state.baseAssociation).toBeNull(); expect(state.loading).toBe(false)
|
||||
})
|
||||
it('distinguishes a server miss from a found relationship with no SKU stock', async () => {
|
||||
api.overseas.mockResolvedValueOnce({ ...overseas(), found: false, inventory: [] })
|
||||
const miss = await open({ canReadBase: false, canReadLoading: false })
|
||||
expect(miss.wrapper.text()).toContain('服务端未找到该入仓码的海外关联数据'); expect(miss.wrapper.text()).not.toContain('暂无SKU库存箱数记录')
|
||||
api.overseas.mockResolvedValueOnce({ ...overseas(), inventory: [] })
|
||||
const empty = await open({ canReadBase: false, canReadLoading: false })
|
||||
expect(empty.wrapper.text()).toContain('暂无SKU库存箱数记录'); expect(empty.wrapper.text()).toContain('PALLET-001、PALLET-002')
|
||||
})
|
||||
})
|
||||
@@ -172,13 +172,14 @@ describe('InboundCodeRelationshipDialog contract', () => {
|
||||
expect(source).not.toContain('{{ baseAssociation.customerId')
|
||||
expect(source).not.toContain('{{ baseAssociation.destinationWarehouseId')
|
||||
expect(source).toContain('text-overflow:ellipsis')
|
||||
expect(source).toContain(':title="display(baseAssociation.inboundCode)"')
|
||||
expect(source).toContain(':title="dense ? undefined : display(baseAssociation.inboundCode)"')
|
||||
expect(source).toContain('<LineClampText v-if="dense" :content="display(baseAssociation.inboundCode)"')
|
||||
})
|
||||
|
||||
it('keeps sections and status badges within the dialog at desktop and narrow widths', () => {
|
||||
expect(source).toContain('class="inbound-relationship-dialog"')
|
||||
expect(source).toContain('width="min(1060px, 94vw)"')
|
||||
expect(source).toContain(':global(.inbound-relationship-dialog .el-dialog__body){box-sizing:border-box;min-width:0;max-height:calc(90vh - 112px);overflow-x:hidden;overflow-y:auto;padding-right:28px}')
|
||||
expect(source).toContain(':global(.inbound-relationship-dialog:not(.admin-density-dialog) .el-dialog__body){box-sizing:border-box;min-width:0;max-height:calc(90vh - 112px);overflow-x:hidden;overflow-y:auto;padding-right:28px}')
|
||||
expect(source).toContain(':global(.inbound-relationship-dialog .app-dialog__body){box-sizing:border-box;width:auto;max-width:100%;min-width:0;overflow-x:hidden}')
|
||||
expect(source).toContain('.relationship-sections{display:grid;width:auto;max-width:100%;min-width:0;grid-template-columns:minmax(0,1fr)')
|
||||
expect(source).toContain('.relationship-section{box-sizing:border-box;width:100%;min-width:0')
|
||||
@@ -230,6 +231,7 @@ describe('InboundCodeRelationshipDialog contract', () => {
|
||||
consumerSources.forEach((consumer) => {
|
||||
expect(consumer).toContain("import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'")
|
||||
expect(consumer).toContain('<InboundCodeRelationshipDialog')
|
||||
expect(consumer).toContain('<InboundCodeRelationshipDialog dense')
|
||||
})
|
||||
|
||||
const inboundTasks = readFileSync(resolve(root, 'src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation, type LoadingAssociationSummary, type OverseasInboundCodeRelationship, type PlatformInboundCodeAssociation } from '@/shared/api/inboundCodeRelations'
|
||||
import ApiErrorMeta from './ApiErrorMeta.vue'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
import LoadErrorState from './LoadErrorState.vue'
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
import LineClampText from './LineClampText.vue'
|
||||
|
||||
const props = defineProps<{ dense?: boolean; modelValue: boolean; inboundCode: string; canReadBase: boolean; canReadLoading: boolean; canReadOverseas: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
@@ -28,11 +29,16 @@ function display(value: unknown, fallback = '—') { return String(value ?? '').
|
||||
function overseasWarehouseName(value: OverseasInboundCodeRelationship['overseasWarehouse']) {
|
||||
return value ? display(value.warehouseName, '仓库名称待补充') : '尚未分配海外仓'
|
||||
}
|
||||
const taillegSummary = computed(() => overseas.value?.taillegOrders.map((item) => `${item.orderNo}${item.orderStatus ? `(${item.orderStatus})` : ''}`).filter(Boolean).join('、') || '—')
|
||||
const inboundPlanSummary = computed(() => overseas.value?.inboundPlans.map((item) => `${item.planNo}${item.planStatus ? `(${item.planStatus})` : ''}${item.receivingStatus ? ` · ${item.receivingStatus}` : ''}`).join('、') || '—')
|
||||
const palletSummary = computed(() => overseas.value?.palletCodes.join('、') || '—')
|
||||
|
||||
function close(value: boolean) { emit('update:modelValue', value) }
|
||||
|
||||
async function load() {
|
||||
const code = normalizedCode.value
|
||||
const request = ++serial
|
||||
loading.value = false
|
||||
baseAssociation.value = null; overseas.value = null; loadingAssociation.value = null; baseError.value = null; overseasError.value = null; loadingAssociationError.value = null
|
||||
if (!code || (!canReadBase.value && !canReadLoading.value && !canReadOverseas.value)) return
|
||||
loading.value = true
|
||||
@@ -46,48 +52,65 @@ async function load() {
|
||||
|
||||
watch(() => [props.modelValue, props.inboundCode, props.canReadBase, props.canReadLoading, props.canReadOverseas] as const, ([open]) => {
|
||||
if (open) void load()
|
||||
else serial += 1
|
||||
else { serial += 1; loading.value = false }
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { serial += 1; loading.value = false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog class="inbound-relationship-dialog" :class="{ 'admin-density-dialog': dense }" :model-value="modelValue" :title="`入仓码关联数据 · ${normalizedCode || '未提供'}`" width="min(1060px, 94vw)" :loading="loading" :show-footer="false" destroy-on-close @update:model-value="close">
|
||||
<AppDialog class="inbound-relationship-dialog" :class="{ 'admin-density-dialog': dense }" :model-value="modelValue" :title="`入仓码关联数据 · ${normalizedCode || '未提供'}`" width="min(1060px, 94vw)" :fullscreenable="dense" :loading="loading" :show-footer="false" destroy-on-close @update:model-value="close">
|
||||
<el-alert v-if="!normalizedCode" type="warning" :closable="false" show-icon title="缺少入仓码,已停止查询关联数据。" />
|
||||
<el-alert v-else-if="!canReadBase && !canReadLoading && !canReadOverseas" type="warning" :closable="false" show-icon title="当前账号无入仓关联数据查看权限。" />
|
||||
|
||||
<div v-else class="relationship-sections">
|
||||
<section v-if="canReadBase" class="relationship-section" aria-label="订单基础关联">
|
||||
<header><h3>订单基础信息</h3><StatusBadge v-if="baseAssociation?.orderStatus" class="relationship-status" :status="baseAssociation.orderStatus" /></header>
|
||||
<div v-else class="relationship-sections" :class="{ 'density-dialog-sections': dense }">
|
||||
<section v-if="canReadBase" class="relationship-section" :class="{ 'density-dialog-section': dense }" :data-tone="dense ? 'source' : undefined" aria-label="订单基础关联">
|
||||
<header :class="{ 'density-dialog-section__title': dense }"><h3>订单基础信息</h3><StatusBadge v-if="baseAssociation?.orderStatus" class="relationship-status" :status="baseAssociation.orderStatus" /></header>
|
||||
<LoadErrorState v-if="baseError" title="订单基础关联加载失败" :message="baseError.message" @retry="load"><template #details><ApiErrorMeta :details="baseError" /></template></LoadErrorState>
|
||||
<dl v-else-if="baseAssociation" class="compact-facts" :class="{ 'density-fields': dense }">
|
||||
<div><dt>入仓码</dt><dd :title="display(baseAssociation.inboundCode)">{{ display(baseAssociation.inboundCode) }}</dd></div><div><dt>订单编码</dt><dd :title="display(baseAssociation.orderNo, '订单编码待补充')">{{ display(baseAssociation.orderNo, '订单编码待补充') }}</dd></div>
|
||||
<div><dt>客户</dt><dd :title="display(baseAssociation.customerName, '客户名称待补充')">{{ display(baseAssociation.customerName, '客户名称待补充') }}</dd></div><div><dt>目的类型</dt><dd>{{ display(baseAssociation.destinationType) }}</dd></div>
|
||||
<div><dt>目的国家</dt><dd>{{ display(baseAssociation.destinationCountryCode) }}</dd></div><div><dt>目的仓</dt><dd>{{ display(baseAssociation.destinationWarehouseName, '目的仓名称待补充') }}<small v-if="baseAssociation.destinationWarehouseCode">{{ baseAssociation.destinationWarehouseCode }}</small></dd></div>
|
||||
<div><dt>入仓码</dt><dd :title="dense ? undefined : display(baseAssociation.inboundCode)"><LineClampText v-if="dense" :content="display(baseAssociation.inboundCode)" /><template v-else>{{ display(baseAssociation.inboundCode) }}</template></dd></div>
|
||||
<div><dt>订单编码</dt><dd :title="dense ? undefined : display(baseAssociation.orderNo, '订单编码待补充')"><LineClampText v-if="dense" :content="display(baseAssociation.orderNo, '订单编码待补充')" /><template v-else>{{ display(baseAssociation.orderNo, '订单编码待补充') }}</template></dd></div>
|
||||
<div><dt>客户</dt><dd :title="dense ? undefined : display(baseAssociation.customerName, '客户名称待补充')"><LineClampText v-if="dense" :content="display(baseAssociation.customerName, '客户名称待补充')" /><template v-else>{{ display(baseAssociation.customerName, '客户名称待补充') }}</template></dd></div>
|
||||
<div><dt>目的类型</dt><dd><LineClampText v-if="dense" :content="display(baseAssociation.destinationType)" /><template v-else>{{ display(baseAssociation.destinationType) }}</template></dd></div>
|
||||
<div><dt>目的国家</dt><dd><LineClampText v-if="dense" :content="display(baseAssociation.destinationCountryCode)" /><template v-else>{{ display(baseAssociation.destinationCountryCode) }}</template></dd></div>
|
||||
<div><dt>目的仓</dt><dd><LineClampText v-if="dense" :content="display(baseAssociation.destinationWarehouseName, '目的仓名称待补充') + (baseAssociation.destinationWarehouseCode ? '(' + baseAssociation.destinationWarehouseCode + ')' : '')" /><template v-else>{{ display(baseAssociation.destinationWarehouseName, '目的仓名称待补充') }}<small v-if="baseAssociation.destinationWarehouseCode">{{ baseAssociation.destinationWarehouseCode }}</small></template></dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="canReadLoading" class="relationship-section" aria-label="装柜与PDA关联">
|
||||
<header><h3>装柜与PDA</h3><StatusBadge v-if="loadingAssociation?.loadingStatus" class="relationship-status" :status="loadingAssociation.loadingStatus" /></header>
|
||||
<section v-if="canReadLoading" class="relationship-section" :class="{ 'density-dialog-section': dense }" :data-tone="dense ? 'source' : undefined" aria-label="装柜与PDA关联">
|
||||
<header :class="{ 'density-dialog-section__title': dense }"><h3>装柜与PDA</h3><StatusBadge v-if="loadingAssociation?.loadingStatus" class="relationship-status" :status="loadingAssociation.loadingStatus" /></header>
|
||||
<LoadErrorState v-if="loadingAssociationError" title="装柜关联加载失败" :message="loadingAssociationError.message" @retry="load"><template #details><ApiErrorMeta :details="loadingAssociationError" /></template></LoadErrorState>
|
||||
<dl v-else-if="loadingAssociation" class="compact-facts" :class="{ 'density-fields': dense }">
|
||||
<div><dt>订单编码</dt><dd :title="display(loadingAssociation.orderNo, '订单编码待补充')">{{ display(loadingAssociation.orderNo, '订单编码待补充') }}</dd></div><div><dt>装柜计划</dt><dd :title="display(loadingAssociation.loadingPlanNo, '尚未进入装柜')">{{ display(loadingAssociation.loadingPlanNo, '尚未进入装柜') }}</dd></div>
|
||||
<div><dt>柜号</dt><dd :title="display(loadingAssociation.containerNo || loadingAssociation.cabinetNo, '尚未关联柜号')">{{ display(loadingAssociation.containerNo || loadingAssociation.cabinetNo, '尚未关联柜号') }}</dd></div><div><dt>同柜组</dt><dd :title="display(loadingAssociation.coLoadGroupNo)">{{ display(loadingAssociation.coLoadGroupNo) }}</dd></div>
|
||||
<div><dt>装柜状态</dt><dd>{{ display(loadingAssociation.loadingStatus) }}</dd></div><div><dt>PDA任务状态</dt><dd>{{ display(loadingAssociation.pdaTaskStatus, '尚未生成PDA任务') }}</dd></div>
|
||||
<div><dt>订单编码</dt><dd :title="dense ? undefined : display(loadingAssociation.orderNo, '订单编码待补充')"><LineClampText v-if="dense" :content="display(loadingAssociation.orderNo, '订单编码待补充')" /><template v-else>{{ display(loadingAssociation.orderNo, '订单编码待补充') }}</template></dd></div>
|
||||
<div><dt>装柜计划</dt><dd :title="dense ? undefined : display(loadingAssociation.loadingPlanNo, '尚未进入装柜')"><LineClampText v-if="dense" :content="display(loadingAssociation.loadingPlanNo, '尚未进入装柜')" /><template v-else>{{ display(loadingAssociation.loadingPlanNo, '尚未进入装柜') }}</template></dd></div>
|
||||
<div><dt>柜号</dt><dd :title="dense ? undefined : display(loadingAssociation.containerNo || loadingAssociation.cabinetNo, '尚未关联柜号')"><LineClampText v-if="dense" :content="display(loadingAssociation.containerNo || loadingAssociation.cabinetNo, '尚未关联柜号')" /><template v-else>{{ display(loadingAssociation.containerNo || loadingAssociation.cabinetNo, '尚未关联柜号') }}</template></dd></div>
|
||||
<div><dt>同柜组</dt><dd :title="dense ? undefined : display(loadingAssociation.coLoadGroupNo)"><LineClampText v-if="dense" :content="display(loadingAssociation.coLoadGroupNo)" /><template v-else>{{ display(loadingAssociation.coLoadGroupNo) }}</template></dd></div>
|
||||
<div><dt>装柜状态</dt><dd><LineClampText v-if="dense" :content="display(loadingAssociation.loadingStatus)" /><template v-else>{{ display(loadingAssociation.loadingStatus) }}</template></dd></div>
|
||||
<div><dt>PDA任务状态</dt><dd><LineClampText v-if="dense" :content="display(loadingAssociation.pdaTaskStatus, '尚未生成PDA任务')" /><template v-else>{{ display(loadingAssociation.pdaTaskStatus, '尚未生成PDA任务') }}</template></dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="canReadOverseas" class="relationship-section" aria-label="海外入仓关联">
|
||||
<header><h3>海外入仓与库存</h3><StatusBadge v-if="overseas?.overseasReceivingStatus" class="relationship-status" :status="overseas.overseasReceivingStatus" /></header>
|
||||
<section v-if="canReadOverseas" class="relationship-section" :class="{ 'density-dialog-section': dense }" :data-tone="dense ? 'summary' : undefined" aria-label="海外入仓关联">
|
||||
<header :class="{ 'density-dialog-section__title': dense }"><h3>海外入仓与库存</h3><StatusBadge v-if="overseas?.overseasReceivingStatus" class="relationship-status" :status="overseas.overseasReceivingStatus" /></header>
|
||||
<LoadErrorState v-if="overseasError" title="海外关联加载失败" :message="overseasError.message" @retry="load"><template #details><ApiErrorMeta :details="overseasError" /></template></LoadErrorState>
|
||||
<el-empty v-else-if="overseas && !overseas.found" description="服务端未找到该入仓码的海外关联数据" :image-size="56" />
|
||||
<template v-else-if="overseas">
|
||||
<dl class="compact-facts" :class="{ 'density-fields': dense }"><div><dt>客户</dt><dd>{{ display(overseas.customer?.customerName, '客户名称待补充') }}</dd></div><div><dt>海外仓</dt><dd>{{ overseasWarehouseName(overseas.overseasWarehouse) }}<small v-if="overseas.overseasWarehouse?.warehouseCode">{{ overseas.overseasWarehouse.warehouseCode }}</small></dd></div></dl>
|
||||
<div class="compact-list"><strong>尾程订单</strong><span :title="overseas.taillegOrders.map((item) => item.orderNo).join('、')">{{ overseas.taillegOrders.map((item) => `${item.orderNo}${item.orderStatus ? `(${item.orderStatus})` : ''}`).filter(Boolean).join('、') || '—' }}</span></div>
|
||||
<div class="compact-list"><strong>入库计划</strong><span :title="overseas.inboundPlans.map((item) => item.planNo).join('、')">{{ overseas.inboundPlans.map((item) => `${item.planNo}${item.planStatus ? `(${item.planStatus})` : ''}${item.receivingStatus ? ` · ${item.receivingStatus}` : ''}`).join('、') || '—' }}</span></div>
|
||||
<div class="compact-list"><strong>托盘号</strong><span :title="overseas.palletCodes.join('、')">{{ overseas.palletCodes.join('、') || '—' }}</span></div>
|
||||
<dl class="compact-facts" :class="{ 'density-fields': dense }">
|
||||
<div><dt>客户</dt><dd><LineClampText v-if="dense" :content="display(overseas.customer?.customerName, '客户名称待补充')" /><template v-else>{{ display(overseas.customer?.customerName, '客户名称待补充') }}</template></dd></div>
|
||||
<div><dt>海外仓</dt><dd><LineClampText v-if="dense" :content="overseasWarehouseName(overseas.overseasWarehouse) + (overseas.overseasWarehouse?.warehouseCode ? '(' + overseas.overseasWarehouse.warehouseCode + ')' : '')" /><template v-else>{{ overseasWarehouseName(overseas.overseasWarehouse) }}<small v-if="overseas.overseasWarehouse?.warehouseCode">{{ overseas.overseasWarehouse.warehouseCode }}</small></template></dd></div>
|
||||
<template v-if="dense">
|
||||
<div class="relationship-wide-field"><dt>尾程订单</dt><dd><LineClampText :content="taillegSummary" :lines="2" /></dd></div>
|
||||
<div class="relationship-wide-field"><dt>入库计划</dt><dd><LineClampText :content="inboundPlanSummary" :lines="2" /></dd></div>
|
||||
<div class="relationship-wide-field"><dt>托盘号</dt><dd><LineClampText :content="palletSummary" :lines="2" /></dd></div>
|
||||
</template>
|
||||
</dl>
|
||||
<template v-if="!dense">
|
||||
<div class="compact-list"><strong>尾程订单</strong><span :title="overseas.taillegOrders.map((item) => item.orderNo).join('、')">{{ taillegSummary }}</span></div>
|
||||
<div class="compact-list"><strong>入库计划</strong><span :title="overseas.inboundPlans.map((item) => item.planNo).join('、')">{{ inboundPlanSummary }}</span></div>
|
||||
<div class="compact-list"><strong>托盘号</strong><span :title="overseas.palletCodes.join('、')">{{ palletSummary }}</span></div>
|
||||
</template>
|
||||
<el-table v-if="overseas.inventory.length" :data="overseas.inventory" size="small" border class="inventory-table">
|
||||
<el-table-column prop="skuCode" label="SKU编码" min-width="150" /><el-table-column label="产品名称" min-width="190"><template #default="{ row }">{{ display(row.productNameZh || row.productNameEn) }}</template></el-table-column><el-table-column prop="packageCount" label="库存箱数" width="110" align="right" />
|
||||
<el-table-column prop="skuCode" label="SKU编码" min-width="150"><template #default="{ row }"><LineClampText v-if="dense" :content="display(row.skuCode)" /><template v-else>{{ row.skuCode }}</template></template></el-table-column>
|
||||
<el-table-column label="产品名称" min-width="190"><template #default="{ row }"><LineClampText v-if="dense" :content="display(row.productNameZh || row.productNameEn)" /><template v-else>{{ display(row.productNameZh || row.productNameEn) }}</template></template></el-table-column>
|
||||
<el-table-column prop="packageCount" label="库存箱数" width="110" align="right" />
|
||||
</el-table>
|
||||
<p v-else-if="dense" class="density-dialog-note">暂无SKU库存箱数记录</p>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
@@ -95,11 +118,28 @@ watch(() => [props.modelValue, props.inboundCode, props.canReadBase, props.canRe
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.inbound-relationship-dialog.el-dialog){width:min(1060px,94vw);max-width:94vw}:global(.inbound-relationship-dialog .el-dialog__body){box-sizing:border-box;min-width:0;max-height:calc(90vh - 112px);overflow-x:hidden;overflow-y:auto;padding-right:28px}:global(.inbound-relationship-dialog .app-dialog__body){box-sizing:border-box;width:auto;max-width:100%;min-width:0;overflow-x:hidden}.relationship-sections{display:grid;width:auto;max-width:100%;min-width:0;grid-template-columns:minmax(0,1fr);gap:12px}.relationship-section{box-sizing:border-box;width:100%;min-width:0;border:1px solid var(--cc-border);border-radius:8px;padding:12px;background:var(--cc-surface)}.relationship-section>header{display:flex;min-width:0;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px 12px;margin-bottom:10px}.relationship-section h3{flex:1 1 auto;min-width:0;margin:0;font-size:15px}.relationship-status{flex:0 0 auto;max-width:100%}.compact-facts:not(.density-fields){display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;background:var(--cc-border)}.compact-facts:not(.density-fields)>div{display:grid;min-width:0;grid-template-columns:92px minmax(0,1fr);gap:8px;padding:8px 10px;background:var(--cc-surface)}.compact-facts:not(.density-fields) dt{color:var(--cc-muted)}.compact-facts:not(.density-fields) dd{min-width:0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compact-facts:not(.density-fields) small{display:block;overflow:hidden;color:var(--cc-muted);margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.compact-list{display:grid;min-width:0;grid-template-columns:92px minmax(0,1fr);gap:8px;padding:8px 10px;border-top:1px solid var(--cc-border)}.compact-list span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.inventory-table{width:100%;max-width:100%;min-width:0;margin-top:8px}@media(max-width:720px){:global(.inbound-relationship-dialog .el-dialog__body){padding-right:20px}.compact-facts:not(.density-fields){grid-template-columns:1fr}}
|
||||
|
||||
:global(.inbound-relationship-dialog.admin-density-dialog .el-dialog__body) { padding: var(--admin-space-5) var(--admin-space-6); max-height: none; }
|
||||
:global(.inbound-relationship-dialog.admin-density-dialog .relationship-section) { padding: 0; border: 0; border-radius: 0; }
|
||||
:global(.inbound-relationship-dialog.admin-density-dialog .relationship-section h3) { font-size: var(--admin-font-section); }
|
||||
:global(.inbound-relationship-dialog.admin-density-dialog .density-fields) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
:global(.inbound-relationship-dialog.admin-density-dialog .compact-list span) { white-space: normal; overflow-wrap: anywhere; }
|
||||
:global(.inbound-relationship-dialog:not(.admin-density-dialog).el-dialog){width:min(1060px,94vw);max-width:94vw}
|
||||
:global(.inbound-relationship-dialog:not(.admin-density-dialog) .el-dialog__body){box-sizing:border-box;min-width:0;max-height:calc(90vh - 112px);overflow-x:hidden;overflow-y:auto;padding-right:28px}
|
||||
:global(.inbound-relationship-dialog .app-dialog__body){box-sizing:border-box;width:auto;max-width:100%;min-width:0;overflow-x:hidden}
|
||||
.relationship-sections{display:grid;width:auto;max-width:100%;min-width:0;grid-template-columns:minmax(0,1fr)}
|
||||
.relationship-sections:not(.density-dialog-sections){gap:12px}
|
||||
.relationship-section{box-sizing:border-box;width:100%;min-width:0}
|
||||
.relationship-section:not(.density-dialog-section){border:1px solid var(--cc-border);border-radius:8px;padding:12px;background:var(--cc-surface)}
|
||||
.relationship-section>header{display:flex;min-width:0;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px 12px}
|
||||
.relationship-section:not(.density-dialog-section)>header{margin-bottom:10px}
|
||||
.relationship-section h3{flex:1 1 auto;min-width:0;margin:0}
|
||||
.relationship-section:not(.density-dialog-section) h3{font-size:15px}
|
||||
.relationship-section.density-dialog-section h3{font:inherit}
|
||||
.relationship-status{flex:0 0 auto;max-width:100%}
|
||||
.compact-facts:not(.density-fields){display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;background:var(--cc-border)}
|
||||
.compact-facts:not(.density-fields)>div{display:grid;min-width:0;grid-template-columns:92px minmax(0,1fr);gap:8px;padding:8px 10px;background:var(--cc-surface)}
|
||||
.compact-facts:not(.density-fields) dt{color:var(--cc-muted)}
|
||||
.compact-facts:not(.density-fields) dd{min-width:0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.compact-facts:not(.density-fields) small{display:block;overflow:hidden;color:var(--cc-muted);margin-top:2px;text-overflow:ellipsis;white-space:nowrap}
|
||||
.compact-facts.density-fields{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.density-fields>.relationship-wide-field{grid-column:1 / -1}
|
||||
.relationship-section:not(.density-dialog-section) .compact-list{display:grid;min-width:0;grid-template-columns:92px minmax(0,1fr);gap:8px;padding:8px 10px;border-top:1px solid var(--cc-border)}
|
||||
.relationship-section:not(.density-dialog-section) .compact-list span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.inventory-table{width:100%;max-width:100%;min-width:0;margin-top:8px}
|
||||
@media(max-width:720px){:global(.inbound-relationship-dialog:not(.admin-density-dialog) .el-dialog__body){padding-right:20px}.compact-facts:not(.density-fields){grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import InlineHelp from './InlineHelp.vue'
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
function help() {
|
||||
vi.useFakeTimers()
|
||||
return mount(InlineHelp, {
|
||||
props: { title: '计费说明' },
|
||||
slots: { default: '<p>实重与体积重取大计费。</p><p>费用确认后才发布。</p>' },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
}
|
||||
|
||||
function visiblePopup() {
|
||||
const popup = document.querySelector('.inline-help__content')?.closest<HTMLElement>('[role="tooltip"]')
|
||||
return popup && popup.getAttribute('aria-hidden') !== 'true' && popup.style.display !== 'none' ? popup : null
|
||||
}
|
||||
|
||||
describe('InlineHelp', () => {
|
||||
it('renders an accessible icon without an expandable row or inline rule text', () => {
|
||||
const wrapper = help()
|
||||
expect(wrapper.get('button').attributes('aria-label')).toBe('查看计费说明')
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
expect(wrapper.find('details').exists()).toBe(false)
|
||||
expect(wrapper.find('p').exists()).toBe(false)
|
||||
expect(visiblePopup()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows rules on hover in a floating tooltip and hides them after leaving', async () => {
|
||||
const wrapper = help()
|
||||
await wrapper.get('button').trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await flushPromises()
|
||||
expect(visiblePopup()?.textContent).toContain('实重与体积重取大计费。')
|
||||
expect(visiblePopup()?.textContent).toContain('费用确认后才发布。')
|
||||
expect(wrapper.find('p').exists()).toBe(false)
|
||||
await wrapper.get('button').trigger('mouseleave')
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await flushPromises()
|
||||
expect(visiblePopup()).toBeNull()
|
||||
})
|
||||
|
||||
it('also exposes rules to keyboard focus without expanding the page', async () => {
|
||||
const wrapper = help()
|
||||
await wrapper.get('button').trigger('focus')
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await flushPromises()
|
||||
expect(visiblePopup()?.textContent).toContain('费用确认后才发布。')
|
||||
expect(wrapper.find('details').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { Info } from '@lucide/vue'
|
||||
withDefaults(defineProps<{ title?: string }>(), { title: '操作说明' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-tooltip :trigger="['hover', 'focus']" placement="top" effect="light" :show-after="150" :hide-after="100" :enterable="true">
|
||||
<template #content><div class="inline-help__content"><strong>{{ title }}</strong><slot /></div></template>
|
||||
<button type="button" class="inline-help__trigger" :aria-label="`查看${title}`"><Info :size="14" aria-hidden="true" /></button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inline-help__trigger { display: inline-flex; width: 24px; height: 24px; flex: 0 0 24px; align-items: center; justify-content: center; margin: 0; padding: 0; border: 0; background: transparent; color: var(--cc-muted, #667085); cursor: help; vertical-align: middle; }
|
||||
.inline-help__trigger:hover, .inline-help__trigger:focus-visible { color: var(--cc-primary, #1677ff); }
|
||||
.inline-help__trigger:focus-visible { outline: 2px solid var(--cc-primary, #1677ff); outline-offset: 2px; }
|
||||
.inline-help__content { max-width: 320px; color: var(--cc-text, #172033); font-size: var(--admin-font-caption, 12px); font-weight: 400; line-height: 1.6; white-space: normal; }
|
||||
.inline-help__content > strong { display: block; margin-bottom: var(--admin-space-1, 4px); }
|
||||
.inline-help__content :deep(p) { margin: 0; }
|
||||
.inline-help__content :deep(p + p) { margin-top: var(--admin-space-1, 4px); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user