Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev

This commit is contained in:
2026-09-01 10:18:48 +08:00
221 changed files with 6764 additions and 5701 deletions
+7
View File
@@ -66,6 +66,13 @@ describe('API error diagnostics', () => {
}
})
it('translates notification recipient error codes into actionable Chinese messages', () => {
expect(getApiErrorDetails(new ApiResponseError('NOTIFICATION_RECIPIENT_FORBIDDEN', 'A0400')).message)
.toBe('角色编码不存在或当前账号无权使用该角色,请检查后重试')
expect(getApiErrorDetails(new ApiResponseError('NOTIFICATION_RECIPIENT_EMPTY', 'A0400')).message)
.toBe('该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员')
})
it('preserves a backend credential error for an explicit login request', () => {
const error = new ApiResponseError(
'用户名或密码错误',
+11 -2
View File
@@ -53,6 +53,15 @@ let authRefreshHandler: AuthRefreshHandler | null = null
let authSessionExpiredHandler: AuthSessionExpiredHandler | null = null
let activeRefresh: Promise<string> | null = null
const businessErrorMessages: Record<string, string> = {
NOTIFICATION_RECIPIENT_FORBIDDEN: '角色编码不存在或当前账号无权使用该角色,请检查后重试',
NOTIFICATION_RECIPIENT_EMPTY: '该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员',
}
function friendlyBusinessMessage(message?: string | null) {
return (message && businessErrorMessages[message]) || message || undefined
}
export function registerAuthRefreshHandler(handler: AuthRefreshHandler) {
authRefreshHandler = handler
}
@@ -134,7 +143,7 @@ export function getApiErrorDetails(
? '当前账号无权访问该数据,请联系管理员确认权限'
: error.code.startsWith('S05')
? '服务暂时不可用,请稍后重试'
: error.message
: friendlyBusinessMessage(error.message) || '接口请求失败'
return { message, code: error.code, requestId: error.requestId, traceId: error.traceId }
}
if (axios.isAxiosError(error)) {
@@ -156,7 +165,7 @@ export function getApiErrorDetails(
? '服务暂时不可用,请稍后重试'
: undefined
return {
message: statusMessage || response?.message || response?.error || fallback,
message: statusMessage || friendlyBusinessMessage(response?.message || response?.error) || fallback,
code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined),
requestId: response?.requestId || undefined,
traceId: response?.traceId || undefined,
@@ -0,0 +1,27 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from './http'
import { overseasHttp } from './overseasHttp'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation } from './inboundCodeRelations'
vi.mock('./http', async (original) => ({ ...(await original<typeof import('./http')>()), http: { get: vi.fn() } }))
vi.mock('./overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
describe('inbound-code relationship APIs', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the frozen domestic and overseas read models and encodes the business code', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', orderNo: 'ORD-1' } })
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', found: false, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [] } })
await getPlatformInboundCodeAssociation('IN/01')
await getLoadingAssociationSummary('IN/01')
await getOverseasInboundCodeRelationship('IN/01')
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/inbound-code-associations/IN%2F01')
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/loading-associations/summary', { params: { inboundCode: 'IN/01' } })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/inbound-code-relationships/IN%2F01')
})
it('does not accept customer or internal ids as lookup parameters', () => {
const source = `${getPlatformInboundCodeAssociation}${getLoadingAssociationSummary}${getOverseasInboundCodeRelationship}`
expect(source).not.toMatch(/customerId|inboundId|orderId|warehouseId/)
})
})
+75
View File
@@ -0,0 +1,75 @@
import { http, unwrapApiResponse, type ApiEnvelope } from './http'
import { overseasHttp } from './overseasHttp'
export interface PlatformInboundCodeAssociation {
inboundCode: string
orderNo: string
orderStatus?: string | null
orderId: string
customerId: string
customerName: string
destinationType?: string | null
destinationCountryCode?: string | null
destinationWarehouseId?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
}
export interface OverseasInboundCodeRelationship {
inboundCode: string
found: boolean
customer: { customerId: string; customerName: string } | null
overseasWarehouse: { warehouseId: string; warehouseCode: string; warehouseName: string } | null
overseasReceivingStatus?: string | null
taillegOrders: Array<{ orderNo: string; orderStatus?: string | null }>
inboundPlans: Array<{ planNo: string; planStatus?: string | null; receivingStatus?: string | null }>
palletCodes: string[]
inventory: Array<{ skuCode: string; productNameZh?: string | null; productNameEn?: string | null; packageCount: number }>
}
export interface LoadingAssociationSummary {
inboundId: string
inboundCode: string
orderNo?: string | null
loadingPlanId?: string | null
loadingPlanNo?: string | null
containerBookingId?: string | null
cabinetNo?: string | null
containerNo?: string | null
loadingStatus?: string | null
taskId?: string | null
pdaTaskStatus?: string | null
coLoadGroupId?: string | null
coLoadGroupNo?: string | null
items: Array<{ itemId: string }>
}
export async function getPlatformInboundCodeAssociation(inboundCode: string) {
const response = await http.get<ApiEnvelope<PlatformInboundCodeAssociation>, ApiEnvelope<PlatformInboundCodeAssociation>>(
`/platform/v1/orders/first-leg/inbound-code-associations/${encodeURIComponent(inboundCode)}`,
)
return unwrapApiResponse(response)
}
export async function getLoadingAssociationSummary(inboundCode: string) {
const response = await http.get<ApiEnvelope<LoadingAssociationSummary>, ApiEnvelope<LoadingAssociationSummary>>(
'/platform/v1/first-leg/loading-associations/summary',
{ params: { inboundCode } },
)
return unwrapApiResponse(response)
}
export async function getLoadingAssociationSummaryByInboundId(inboundId: string) {
const response = await http.get<ApiEnvelope<LoadingAssociationSummary>, ApiEnvelope<LoadingAssociationSummary>>(
'/platform/v1/first-leg/loading-associations/summary',
{ params: { inboundId } },
)
return unwrapApiResponse(response)
}
export async function getOverseasInboundCodeRelationship(inboundCode: string) {
const response = await overseasHttp.get<ApiEnvelope<OverseasInboundCodeRelationship>, ApiEnvelope<OverseasInboundCodeRelationship>>(
`/platform/v1/last-leg/inventory/inbound-code-relationships/${encodeURIComponent(inboundCode)}`,
)
return unwrapApiResponse(response)
}
+28 -4
View File
@@ -6,14 +6,14 @@ import LayoutPreferenceList from './LayoutPreferenceList.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
const TableStub = defineComponent({
props: { border: Boolean, stripe: Boolean, maxHeight: [String, Number] },
template: '<div class="table-stub" :data-border="String(border)" :data-stripe="String(stripe)" :data-max-height="String(maxHeight)"><slot /></div>',
props: { border: Boolean, stripe: Boolean, maxHeight: [String, Number], emptyText: String },
template: '<div class="table-stub" :data-border="String(border)" :data-stripe="String(stripe)" :data-max-height="String(maxHeight)" :data-empty-text="emptyText"><slot /></div>',
methods: { doLayout() {} },
})
const ColumnStub = defineComponent({
inheritAttrs: false,
props: { label: String, prop: String },
template: '<div class="column-stub" :data-label="label" :data-prop="prop"><slot /></div>',
props: { label: String, prop: String, fixed: [String, Boolean], showOverflowTooltip: Boolean },
template: '<div class="column-stub" :data-label="label" :data-prop="prop" :data-fixed="String(fixed || \'\')" :data-overflow-tooltip="String(showOverflowTooltip)"><slot /></div>',
})
const ButtonStub = defineComponent({ template: '<button type="button"><slot /></button>' })
const PopoverStub = defineComponent({ template: '<div><slot name="reference" /><slot /></div>' })
@@ -128,6 +128,30 @@ describe('DataTable column preferences', () => {
expect(customTable.get('.table-stub').attributes('data-max-height')).toBe('420')
})
it('keeps fixed actions, long text, empty state, and pagination in the shared list contract', () => {
const wrapper = shallowMount(DataTable, {
props: {
data: [],
columns: [
{ key: 'actions', label: '操作', fixed: 'left', slotName: 'actions' },
{ key: 'description', label: '长文本', showOverflowTooltip: true },
],
emptyText: '暂无符合条件的数据',
total: 0,
},
global: {
stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub },
directives: { loading: {} },
},
})
const columns = wrapper.findAll('.column-stub')
expect(columns[0].attributes('data-fixed')).toBe('left')
expect(columns[1].attributes('data-overflow-tooltip')).toBe('true')
expect(wrapper.get('.table-stub').attributes('data-empty-text')).toBe('暂无符合条件的数据')
expect(wrapper.get('.data-table__pagination').text()).toContain('共 0 条')
})
it('uses key as the data property for ordinary columns only', () => {
const wrapper = shallowMount(DataTable, {
props: {
+6 -2
View File
@@ -358,6 +358,10 @@ function setPage(value: number) {
<style scoped>
.data-table { min-width: 0; }
.data-table :deep(.el-table) { font-size: var(--cc-list-table-font-size); }
.data-table :deep(.el-table th.el-table__cell) { font-size: var(--cc-list-table-font-size); font-weight: var(--cc-list-table-header-font-weight); }
.data-table :deep(.el-table .el-button) { font-size: var(--cc-list-action-font-size); }
.data-table :deep(.el-table__empty-text) { font-size: var(--cc-list-table-font-size); }
.data-table__toolbar { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }
.data-table__toolbar.is-compact { min-height: 56px; justify-content: flex-end; padding: 8px 14px; }
.data-table__toolbar-start { min-width: 0; flex: 1; }
@@ -367,13 +371,13 @@ function setPage(value: number) {
.data-table__business-actions, .data-table__utility-actions, .data-table__toolbar-end { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.data-table__utility-actions { flex: 0 0 auto; }
.data-table__toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: var(--cc-list-pagination-font-size); }
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
.data-table__pagination-controls { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
.data-table__page-size :deep(.el-select) { width: 82px; }
.data-table__page-buttons { gap: 4px; }
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; font-size: var(--cc-list-page-button-font-size); }
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
@media (min-width: 1920px) {
@@ -0,0 +1,240 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import {
getLoadingAssociationSummary,
getOverseasInboundCodeRelationship,
getPlatformInboundCodeAssociation,
} from '@/shared/api/inboundCodeRelations'
import InboundCodeRelationshipDialog from './InboundCodeRelationshipDialog.vue'
vi.mock('@/shared/api/inboundCodeRelations', () => ({
getLoadingAssociationSummary: vi.fn(),
getOverseasInboundCodeRelationship: vi.fn(),
getPlatformInboundCodeAssociation: vi.fn(),
}))
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/InboundCodeRelationshipDialog.vue'), 'utf8')
const AppDialogStub = defineComponent({
props: { modelValue: Boolean, title: String, loading: Boolean },
emits: ['update:modelValue'],
template: '<aside><h2>{{ title }}</h2><slot /></aside>',
})
function mountDialog() {
return mount(InboundCodeRelationshipDialog, {
props: {
modelValue: true,
inboundCode: 'NYEXX2608300027',
canReadBase: true,
canReadLoading: true,
canReadOverseas: true,
},
global: {
stubs: {
AppDialog: AppDialogStub,
ApiErrorMeta: true,
LoadErrorState: { props: ['title', 'message'], template: '<div>{{ title }} · {{ message }}</div>' },
StatusBadge: true,
'el-alert': true,
'el-empty': { props: ['description'], template: '<p>{{ description }}</p>' },
'el-table': { props: ['data'], template: '<div>{{ data[0]?.skuCode }}<slot /></div>' },
'el-table-column': true,
},
},
})
}
describe('InboundCodeRelationshipDialog contract', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getPlatformInboundCodeAssociation).mockResolvedValue({
inboundCode: 'NYEXX2608300027', orderNo: 'FL2093975748931063810', orderStatus: 'APPROVED',
orderId: '90071992547409931', customerId: '90071992547409932', customerName: '纽约客户',
destinationType: 'FBA', destinationWarehouseId: '90071992547409933', destinationWarehouseCode: 'ABE3', destinationWarehouseName: '亚马逊 ABE3 仓',
})
vi.mocked(getLoadingAssociationSummary).mockResolvedValue({
inboundId: '90071992547409934', inboundCode: 'NYEXX2608300027', orderNo: 'FL2093975748931063810',
loadingPlanId: '90071992547409935', loadingPlanNo: 'LP-260830-0016', containerBookingId: '90071992547409936',
cabinetNo: 'CAB-260830-0016', containerNo: null, loadingStatus: 'LOADING', taskId: '90071992547409937',
pdaTaskStatus: 'IN_PROGRESS', coLoadGroupId: null, coLoadGroupNo: null, items: [{ itemId: '90071992547409938' }],
})
vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValue({
inboundCode: 'NYEXX2608300027', found: true,
customer: { customerId: '90071992547409932', customerName: '纽约客户' },
overseasWarehouse: { warehouseId: '90071992547409939', warehouseCode: 'LAX', warehouseName: '洛杉矶海外仓' },
overseasReceivingStatus: 'RECEIVING', taillegOrders: [{ orderNo: 'LL-260830-0001', orderStatus: 'PENDING' }],
inboundPlans: [{ planNo: 'IP-260830-0001', planStatus: 'READY', receivingStatus: 'WAITING' }],
palletCodes: ['PLT-LAX-001'], inventory: [{ skuCode: 'SKU-001', productNameZh: '测试商品', packageCount: 12 }],
})
})
it('loads all three sections on an initially open mount and reloads after closing and reopening', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(getPlatformInboundCodeAssociation).toHaveBeenCalledWith('NYEXX2608300027')
expect(getLoadingAssociationSummary).toHaveBeenCalledWith('NYEXX2608300027')
expect(getOverseasInboundCodeRelationship).toHaveBeenCalledWith('NYEXX2608300027')
expect(wrapper.text()).toContain('FL2093975748931063810')
expect(wrapper.text()).toContain('LP-260830-0016')
expect(wrapper.text()).toContain('CAB-260830-0016')
expect(wrapper.text()).toContain('纽约客户')
expect(wrapper.text()).toContain('洛杉矶海外仓')
expect(wrapper.text()).toContain('LL-260830-0001')
expect(wrapper.text()).toContain('IP-260830-0001')
expect(wrapper.text()).toContain('PLT-LAX-001')
expect(wrapper.text()).toContain('SKU-001')
expect(wrapper.text()).not.toMatch(/9007199254740993[1-9]/)
expect(wrapper.find('.relationship-sections').exists()).toBe(true)
expect(wrapper.findAll('.relationship-section')).toHaveLength(3)
expect(wrapper.findAll('.relationship-status')).toHaveLength(3)
await wrapper.setProps({ modelValue: false })
await wrapper.setProps({ modelValue: true })
await flushPromises()
expect(getPlatformInboundCodeAssociation).toHaveBeenCalledTimes(2)
expect(getLoadingAssociationSummary).toHaveBeenCalledTimes(2)
expect(getOverseasInboundCodeRelationship).toHaveBeenCalledTimes(2)
})
it('keeps a server-declared overseas miss as a local empty state', async () => {
vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValueOnce({
inboundCode: 'NYEXX2608300027', found: false, customer: null, overseasWarehouse: null,
overseasReceivingStatus: null, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [],
})
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.text()).toContain('服务端未找到该入仓码的海外关联数据')
expect(wrapper.text()).toContain('FL2093975748931063810')
expect(wrapper.text()).toContain('LP-260830-0016')
})
it('keeps a failed section local while the other successful sections stay visible', async () => {
vi.mocked(getOverseasInboundCodeRelationship).mockRejectedValueOnce(new Error('overseas unavailable'))
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.text()).toContain('海外关联加载失败')
expect(wrapper.text()).toContain('FL2093975748931063810')
expect(wrapper.text()).toContain('LP-260830-0016')
expect(wrapper.text()).not.toContain('洛杉矶海外仓')
})
it('distinguishes an unassigned overseas warehouse from a missing warehouse name', async () => {
vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValueOnce({
inboundCode: 'NYEXX2608300027', found: true,
customer: { customerId: '90071992547409932', customerName: '纽约客户' }, overseasWarehouse: null,
overseasReceivingStatus: 'PENDING_ASSIGNMENT', taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [],
})
const unassigned = mountDialog()
await flushPromises()
expect(unassigned.text()).toContain('尚未分配海外仓')
expect(unassigned.text()).not.toContain('仓库名称待补充')
unassigned.unmount()
vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValueOnce({
inboundCode: 'NYEXX2608300027', found: true,
customer: { customerId: '90071992547409932', customerName: '纽约客户' },
overseasWarehouse: { warehouseId: '90071992547409939', warehouseCode: 'LAX', warehouseName: '' },
overseasReceivingStatus: 'PENDING_ASSIGNMENT', taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [],
})
const missingName = mountDialog()
await flushPromises()
expect(missingName.text()).toContain('仓库名称待补充')
expect(missingName.text()).not.toContain('尚未分配海外仓')
missingName.unmount()
})
it('loads only permission-authorized server read models and keeps failures isolated', () => {
expect(source).toContain('props.canReadBase')
expect(source).toContain('props.canReadLoading')
expect(source).toContain('props.canReadOverseas')
expect(source).toContain('getPlatformInboundCodeAssociation(code)')
expect(source).toContain('getLoadingAssociationSummary(code)')
expect(source).toContain('getOverseasInboundCodeRelationship(code)')
expect(source).toContain('await Promise.all(tasks)')
expect(source).toContain('{ immediate: true }')
expect(source).toContain('<ApiErrorMeta :details="baseError"')
expect(source).toContain('<ApiErrorMeta :details="overseasError"')
expect(source).toContain('服务端未找到该入仓码的海外关联数据')
expect(source).not.toMatch(/listPlatform|customerId\)|warehouseId\)/)
})
it('shows business numbers and names without rendering internal ids', () => {
expect(source).toContain('baseAssociation.inboundCode')
expect(source).toContain('baseAssociation.orderNo')
expect(source).toContain('baseAssociation.customerName')
expect(source).toContain('baseAssociation.destinationWarehouseName')
expect(source).not.toContain('{{ baseAssociation.orderId')
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)"')
})
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 .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')
expect(source).toContain('flex-wrap:wrap')
expect(source).toContain('.relationship-section h3{flex:1 1 auto;min-width:0')
expect(source).toContain('.relationship-status{flex:0 0 auto;max-width:100%}')
expect(source).toContain('.inventory-table{width:100%;max-width:100%;min-width:0')
})
it('mounts the containment class through the real AppDialog and Element Plus dialog DOM', async () => {
const wrapper = mount(InboundCodeRelationshipDialog, {
attachTo: document.body,
props: {
modelValue: true,
inboundCode: 'NYEXX2608300027',
canReadBase: true,
canReadLoading: true,
canReadOverseas: true,
},
global: { plugins: [ElementPlus] },
})
await flushPromises()
const dialog = document.querySelector('.inbound-relationship-dialog')
const elementBody = dialog?.querySelector('.el-dialog__body')
const appBody = dialog?.querySelector('.app-dialog__body')
expect(dialog).not.toBeNull()
expect(elementBody).not.toBeNull()
expect(appBody).not.toBeNull()
expect(appBody?.querySelectorAll('.relationship-section')).toHaveLength(3)
expect(appBody?.querySelectorAll('.relationship-status')).toHaveLength(3)
expect(dialog?.querySelector('.el-drawer__body')).toBeNull()
wrapper.unmount()
})
it('removes the retired Drawer component and all Admin consumer references', () => {
const root = process.cwd()
expect(existsSync(resolve(root, 'src/shared/components/InboundCodeRelationshipDrawer.vue'))).toBe(false)
expect(existsSync(resolve(root, 'src/shared/components/InboundCodeRelationshipDrawer.test.ts'))).toBe(false)
const dialogConsumerFiles = [
'src/modules/inbounds/pages/InboundOrdersView.vue',
'src/modules/inbounds/components/InboundTaskList.vue',
'src/modules/overseas-warehouse/pages/InventoryTransactionsView.vue',
]
const consumerSources = dialogConsumerFiles.map((file) => readFileSync(resolve(root, file), 'utf8'))
const consumers = consumerSources.join('\n')
expect(consumers).not.toContain('InboundCodeRelationshipDrawer')
expect(consumers).not.toMatch(/relationshipOpen|relationshipInboundCode|inboundRelationshipOpen/)
consumerSources.forEach((consumer) => {
expect(consumer).toContain("import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'")
expect(consumer).toContain('<InboundCodeRelationshipDialog')
})
const unifiedOrders = readFileSync(resolve(root, 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8')
expect(unifiedOrders).not.toContain('InboundCodeRelationshipDrawer')
expect(unifiedOrders).not.toContain('InboundCodeRelationshipDialog')
})
})
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, 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'
const props = defineProps<{ modelValue: boolean; inboundCode: string; canReadBase: boolean; canReadLoading: boolean; canReadOverseas: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const baseAssociation = ref<PlatformInboundCodeAssociation | null>(null)
const overseas = ref<OverseasInboundCodeRelationship | null>(null)
const loadingAssociation = ref<LoadingAssociationSummary | null>(null)
const baseError = ref<ApiErrorDetails | null>(null)
const overseasError = ref<ApiErrorDetails | null>(null)
const loadingAssociationError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
let serial = 0
const canReadBase = computed(() => props.canReadBase)
const canReadLoading = computed(() => props.canReadLoading)
const canReadOverseas = computed(() => props.canReadOverseas)
const normalizedCode = computed(() => props.inboundCode.trim())
function display(value: unknown, fallback = '—') { return String(value ?? '').trim() || fallback }
function overseasWarehouseName(value: OverseasInboundCodeRelationship['overseasWarehouse']) {
return value ? display(value.warehouseName, '仓库名称待补充') : '尚未分配海外仓'
}
function close(value: boolean) { emit('update:modelValue', value) }
async function load() {
const code = normalizedCode.value
const request = ++serial
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
const tasks: Promise<void>[] = []
if (canReadBase.value) tasks.push(getPlatformInboundCodeAssociation(code).then((value) => { if (request === serial) baseAssociation.value = value }).catch((cause) => { if (request === serial) baseError.value = getApiErrorDetails(cause, '订单基础关联数据加载失败') }))
if (canReadLoading.value) tasks.push(getLoadingAssociationSummary(code).then((value) => { if (request === serial) loadingAssociation.value = value }).catch((cause) => { if (request === serial) loadingAssociationError.value = getApiErrorDetails(cause, '装柜与PDA关联数据加载失败') }))
if (canReadOverseas.value) tasks.push(getOverseasInboundCodeRelationship(code).then((value) => { if (request === serial) overseas.value = value }).catch((cause) => { if (request === serial) overseasError.value = getApiErrorDetails(cause, '海外入仓关联数据加载失败') }))
await Promise.all(tasks)
if (request === serial) loading.value = false
}
watch(() => [props.modelValue, props.inboundCode, props.canReadBase, props.canReadLoading, props.canReadOverseas] as const, ([open]) => {
if (open) void load()
else serial += 1
}, { immediate: true })
</script>
<template>
<AppDialog class="inbound-relationship-dialog" :model-value="modelValue" :title="`入仓码关联数据 · ${normalizedCode || '未提供'}`" width="min(1060px, 94vw)" :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>
<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">
<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>
</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>
<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">
<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>
</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>
<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"><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>
<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>
</template>
</section>
</div>
</AppDialog>
</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{display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;background:var(--cc-border)}.compact-facts>div{display:grid;min-width:0;grid-template-columns:92px minmax(0,1fr);gap:8px;padding:8px 10px;background:var(--cc-surface)}.compact-facts dt{color:var(--cc-muted)}.compact-facts dd{min-width:0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compact-facts 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{grid-template-columns:1fr}}
</style>
+8 -1
View File
@@ -40,11 +40,13 @@ const props = withDefaults(defineProps<{
searchText?: string
resetText?: string
searchOnReset?: boolean
searchOnClear?: boolean
loading?: boolean
}>(), {
searchText: '查询',
resetText: '重置',
searchOnReset: true,
searchOnClear: true,
loading: false,
})
@@ -146,6 +148,10 @@ function search() {
emit('search', model.value)
}
function clearInput() {
if (props.searchOnClear) search()
}
function reset() {
if (props.loading) return
for (const field of props.fields) model.value[field.key] = resetValue(field)
@@ -182,7 +188,7 @@ defineExpose({ search, reset })
clearable
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
@clear="search"
@clear="clearInput"
@keyup.enter.exact="searchFromTextInput"
>
<template #prefix><Search :size="16" /></template>
@@ -243,6 +249,7 @@ defineExpose({ search, reset })
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 12px 16px; }
.query-toolbar__field, .query-toolbar__control { display: grid; width: var(--query-field-width, 260px); max-width: 100%; min-width: 0; gap: 6px; }
.query-toolbar__field :deep(.el-input), .query-toolbar__field :deep(.el-select), .query-toolbar__field :deep(.el-date-editor) { width: 100%; max-width: 100%; min-width: 0; }
.query-toolbar__field :deep(.el-input__inner), .query-toolbar__field :deep(.el-select__selected-item), .query-toolbar__field :deep(.el-date-editor), .query-toolbar__actions :deep(.el-button) { font-size: var(--cc-list-query-font-size); }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
:global(.cc-layout-preference-popper) { max-width: calc(100vw - 24px); }
+6
View File
@@ -0,0 +1,6 @@
export function saveCsv(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
try { anchor.href = url; anchor.download = fileName; anchor.click() }
finally { URL.revokeObjectURL(url) }
}