This commit is contained in:
2026-08-12 16:31:46 +08:00
parent c15d21b986
commit d8bbfe05f3
40 changed files with 1541 additions and 248 deletions
@@ -15,10 +15,39 @@ describe('InboundWorkflowWorkspace', () => {
expect(source).toContain('getPlatformInbound')
expect(source).toContain('StatusBadge')
expect(source).toContain('label="基本信息"')
expect(source).toContain('label="货物与测量"')
expect(source).toContain('label="货品与测量"')
expect(source).toContain('label="测量审核"')
expect(source).toContain('label="费用信息"')
expect(source).toContain('label="状态与其他"')
expect(source).toContain('label="状态历史"')
expect(source).toContain('label="其他记录"')
})
it('opens measurement editing and review in prototype-sized centered dialogs', () => {
expect(source).toContain('title="编辑入库测量数据"')
expect(source).toContain('width="min(1900px, calc(100vw - 32px))"')
expect(source).toContain('title="审核入库测量数据"')
expect(source).toContain('width="min(1700px, calc(100vw - 32px))"')
expect(source).toContain('height: 92vh')
expect(source).toContain('max-height: 92vh')
expect(source).toContain('.el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto;')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('@media (max-width: 360px)')
})
it('keeps domain writes inside their existing panels and mounts them only in dialog bodies', () => {
expect(source).toContain('@click="measurementDialogOpen = true"')
expect(source).toContain('@click="reviewDialogOpen = true"')
expect(source).toContain(':key="`measurement-dialog-${revision}`"')
expect(source).toContain(':key="`review-dialog-${revision}`"')
expect(source).toContain('@changed="handleReviewChanged"')
expect(source).toContain('<template #footer>')
expect(source).toContain('measurementPanelRef?.saveDraft()')
expect(source).toContain('measurementPanelRef?.submitForReview()')
expect(source).toContain("reviewPanelRef?.openReasonDialog('reject')")
expect(source).toContain('reviewPanelRef?.confirmApprove()')
expect(source).toContain('inbound-operation-dialog__footer')
expect(source).not.toContain('approveMeasurement')
expect(source).not.toContain('submitInboundMeasurement')
})
it('uses the established first-leg detail density without nested framed surfaces', () => {
@@ -54,7 +83,8 @@ describe('InboundWorkflowWorkspace', () => {
it('keeps pallet and location results read-only in the overview', () => {
expect(source).toContain(':pallet-locations="detail.palletLocations"')
expect(source).toContain('label="状态与其他"')
expect(source).toContain(':sections="[\'history\']"')
expect(source).toContain(':sections="[\'allocations\', \'files\', \'notes\', \'remark\']"')
expect(source).not.toContain('reserveLocation')
expect(source).not.toContain('allocatePallet')
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { RefreshCw } from '@lucide/vue'
import { Box, ClipboardCheck, Pencil, RefreshCw } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -27,6 +27,10 @@ const emit = defineEmits<{
const access = useAccessPreviewStore()
const activeTab = ref('overview')
const measurementDialogOpen = ref(false)
const reviewDialogOpen = ref(false)
const measurementPanelRef = ref<InstanceType<typeof InboundMeasurementPanel> | null>(null)
const reviewPanelRef = ref<InstanceType<typeof MeasurementReviewPanel> | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const detail = ref<InboundOrderDetail | null>(null)
@@ -71,6 +75,11 @@ function handleDomainChanged() {
emit('changed')
}
function handleReviewChanged() {
reviewDialogOpen.value = false
handleDomainChanged()
}
watch(
() => [props.customerId, props.inboundId],
() => {
@@ -94,6 +103,7 @@ onBeforeUnmount(() => {
<header class="workspace-header">
<div>
<div class="workspace-title-row">
<span class="workspace-mark" aria-hidden="true"><Box :size="20" /></span>
<h2>入库码:{{ title }}</h2>
<StatusBadge v-if="detail" :status="statusLabel" />
</div>
@@ -124,14 +134,12 @@ onBeforeUnmount(() => {
<el-tab-pane label="基本信息" name="overview">
<InboundSummaryPanel :summary="detail.summary" :public-remark="detail.publicRemark" />
</el-tab-pane>
<el-tab-pane label="货物与测量" name="measurement" lazy>
<el-tab-pane label="货品与测量" name="measurement" lazy>
<div class="workflow-launch-panel">
<div><h3>编辑入库测量数据</h3><p>核对原单数据、确认扫描箱数,并查看服务端计算的重量和体积结果。</p></div>
<el-button type="primary" @click="measurementDialogOpen = true"><Pencil :size="16" />编辑测量数据</el-button>
</div>
<div class="measurement-workflow">
<InboundMeasurementPanel
:key="`measurement-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
<WeightOcrPanel
:key="`weight-${revision}`"
:tenant-id="customerId"
@@ -141,13 +149,10 @@ onBeforeUnmount(() => {
</div>
</el-tab-pane>
<el-tab-pane label="测量审核" name="review" lazy>
<MeasurementReviewPanel
v-if="canViewMeasurement"
:key="`review-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
<div v-if="canViewMeasurement" class="workflow-launch-panel">
<div><h3>审核入库测量数据</h3><p>对照原单、设备实测和称重 OCR 结果后作出审核决定。</p></div>
<el-button type="primary" @click="reviewDialogOpen = true"><ClipboardCheck :size="16" />审核测量数据</el-button>
</div>
<el-alert
v-else
title="无测量审核查看权限"
@@ -165,16 +170,83 @@ onBeforeUnmount(() => {
@changed="handleDomainChanged"
/>
</el-tab-pane>
<el-tab-pane label="状态与其他" name="records" lazy>
<el-tab-pane label="状态历史" name="history" lazy>
<InboundHistoryPanel
:pallet-locations="[]"
:files="[]"
:internal-notes="[]"
:status-history="detail.statusHistory"
:public-remark="null"
:sections="['history']"
/>
</el-tab-pane>
<el-tab-pane label="其他记录" name="records" lazy>
<InboundHistoryPanel
:pallet-locations="detail.palletLocations"
:files="detail.files"
:internal-notes="detail.internalNotes"
:status-history="detail.statusHistory"
:public-remark="null"
:status-history="[]"
:public-remark="detail.publicRemark"
:sections="['allocations', 'files', 'notes', 'remark']"
/>
</el-tab-pane>
</el-tabs>
<el-dialog
v-model="measurementDialogOpen"
class="inbound-operation-dialog inbound-measurement-dialog"
title="编辑入库测量数据"
width="min(1900px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
:close-on-click-modal="false"
>
<div class="inbound-operation-dialog__body">
<InboundMeasurementPanel
ref="measurementPanelRef"
:key="`measurement-dialog-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
</div>
<template #footer>
<div class="inbound-operation-dialog__footer">
<el-button @click="measurementDialogOpen = false">取消</el-button>
<el-button v-if="measurementPanelRef?.canEdit" type="primary" plain :loading="measurementPanelRef?.saving" :disabled="!measurementPanelRef?.mayMaintain" @click="measurementPanelRef?.saveDraft()">保存确认箱数</el-button>
<el-button v-if="measurementPanelRef?.canSubmit" type="primary" :loading="measurementPanelRef?.submitting" :disabled="!measurementPanelRef?.maySubmit" @click="measurementPanelRef?.submitForReview()">提交审核</el-button>
</div>
</template>
</el-dialog>
<el-dialog
v-model="reviewDialogOpen"
class="inbound-operation-dialog inbound-review-dialog"
title="审核入库测量数据"
width="min(1700px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
:close-on-click-modal="false"
>
<div class="inbound-operation-dialog__body">
<MeasurementReviewPanel
v-if="canViewMeasurement"
ref="reviewPanelRef"
:key="`review-dialog-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleReviewChanged"
/>
</div>
<template #footer>
<div class="inbound-operation-dialog__footer">
<el-button :disabled="Boolean(reviewPanelRef?.actionLoading)" @click="reviewDialogOpen = false">取消</el-button>
<el-button v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview" type="danger" plain :loading="reviewPanelRef?.actionLoading === 'reject'" @click="reviewPanelRef?.openReasonDialog('reject')">驳回</el-button>
<el-button v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview" type="primary" :loading="reviewPanelRef?.actionLoading === 'approve'" @click="reviewPanelRef?.confirmApprove()">通过并完成入库</el-button>
<el-button v-if="reviewPanelRef?.isRejected && reviewPanelRef?.canRelease" type="warning" plain :loading="reviewPanelRef?.actionLoading === 'release'" @click="reviewPanelRef?.openReasonDialog('release')">异常放行</el-button>
</div>
</template>
</el-dialog>
</section>
</template>
@@ -182,6 +254,7 @@ onBeforeUnmount(() => {
.inbound-workspace { min-width: 0; }
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px 24px 16px; border-bottom: 1px solid var(--cc-border); }
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
.workspace-mark { display: grid; width: 36px; height: 36px; flex: 0 0 36px; place-items: center; border-radius: 8px; background: #1e293b; color: #fff; }
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 16px; line-height: 1.35; }
.workspace-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.workspace-state { margin: 24px; }
@@ -193,6 +266,19 @@ onBeforeUnmount(() => {
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding: 0 18px; color: #475467; font-size: 13px; font-weight: 500; }
.workflow-tabs :deep(.el-tabs__item.is-active) { color: var(--cc-primary); font-weight: 650; }
.measurement-workflow { display: grid; min-width: 0; gap: 28px; }
.workflow-launch-panel { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 20px; padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.workflow-launch-panel h3 { margin: 0; color: var(--cc-text); font-size: 15px; }
.workflow-launch-panel p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.inbound-operation-dialog__body { min-width: 0; }
.inbound-operation-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.inbound-operation-dialog__footer :deep(.el-button) { margin-left: 0; }
:global(.inbound-operation-dialog.el-dialog) { display: flex; max-width: calc(100vw - 32px); height: 92vh; max-height: 92vh; margin-bottom: 0; flex-direction: column; overflow: hidden; }
:global(.inbound-operation-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 18px 24px; border-bottom: 1px solid var(--cc-border); }
:global(.inbound-operation-dialog .el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 20px 24px; }
:global(.inbound-operation-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 14px 24px; border-top: 1px solid var(--cc-border); }
:global(.inbound-measurement-dialog .measurement-panel) { border: 0; border-radius: 0; box-shadow: none; }
:global(.inbound-measurement-dialog .measurement-panel__heading .measurement-panel__actions), :global(.inbound-measurement-dialog .measurement-panel__footer) { display: none; }
:global(.inbound-review-dialog .review-actions .action-buttons) { display: none; }
@media (max-width: 720px) {
.workspace-header { align-items: stretch; flex-direction: column; padding: 16px; }
.workspace-header :deep(.el-button) { width: 100%; min-height: 44px; }
@@ -200,5 +286,17 @@ onBeforeUnmount(() => {
.workflow-tabs { padding: 0 16px 16px; }
.workflow-tabs :deep(.el-tabs__nav-wrap) { overflow-x: auto; }
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding-inline: 14px; }
.workflow-launch-panel { align-items: stretch; flex-direction: column; padding: 16px; }
.workflow-launch-panel :deep(.el-button) { width: 100%; min-height: 44px; }
:global(.inbound-operation-dialog.el-dialog) { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); height: 92vh; }
:global(.inbound-operation-dialog .el-dialog__header) { padding: 16px; }
:global(.inbound-operation-dialog .el-dialog__body) { padding: 14px; }
.inbound-operation-dialog__footer { display: grid; grid-template-columns: 1fr; }
.inbound-operation-dialog__footer :deep(.el-button) { width: 100%; min-height: 44px; }
}
@media (max-width: 360px) {
:global(.inbound-operation-dialog.el-dialog) { width: calc(100vw - 12px) !important; max-width: calc(100vw - 12px); }
:global(.inbound-operation-dialog .el-dialog__header) { padding: 14px 12px; }
:global(.inbound-operation-dialog .el-dialog__body) { padding: 10px; }
}
</style>
@@ -2,13 +2,20 @@
import type { InboundFileSummary, InboundInternalNote, InboundPalletLocation, InboundStatusHistory } from './types'
import { inboundHistoryActionLabel, inboundProcessStatusLabels, inboundStatusLabel } from '../status'
defineProps<{
const props = withDefaults(defineProps<{
palletLocations: InboundPalletLocation[]
files: InboundFileSummary[]
internalNotes: InboundInternalNote[]
statusHistory: InboundStatusHistory[]
publicRemark: string | null
}>()
sections?: Array<'allocations' | 'files' | 'notes' | 'history' | 'remark'>
}>(), {
sections: () => ['allocations', 'files', 'notes', 'history', 'remark'],
})
function shows(section: 'allocations' | 'files' | 'notes' | 'history' | 'remark') {
return props.sections.includes(section)
}
function value(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function number(value: number | null) { return value === null ? '—' : value.toFixed(3) }
@@ -21,7 +28,7 @@ function date(value: string | null) {
<template>
<div class="history-panel">
<section class="history-section">
<section v-if="shows('allocations')" class="history-section">
<div class="section-heading"><h3>托盘与库位</h3><span>{{ palletLocations.length }} 条分配</span></div>
<div v-if="palletLocations.length" class="allocation-list">
<div v-for="item in palletLocations" :key="`${item.allocationNo}-${item.palletId}-${item.locationId}`" class="allocation-row">
@@ -32,22 +39,22 @@ function date(value: string | null) {
</div>
<p v-else class="muted-empty">暂无托盘库位分配记录</p>
</section>
<section class="history-section">
<section v-if="shows('files')" class="history-section">
<div class="section-heading"><h3>文件记录</h3><span>{{ files.length }} 个文件</span></div>
<div v-if="files.length" class="simple-list"><div v-for="file in files" :key="`${file.fileId}-${file.fileVersion}`"><strong>{{ value(file.fileNameSnapshot) }}</strong><span>{{ value(file.fileType) }} · {{ date(file.uploadedAt) }}</span></div></div>
<p v-else class="muted-empty">暂无文件记录</p>
</section>
<section class="history-section">
<section v-if="shows('notes')" class="history-section">
<div class="section-heading"><h3>内部备注</h3><span>{{ internalNotes.length }} 条</span></div>
<div v-if="internalNotes.length" class="simple-list"><div v-for="note in internalNotes" :key="String(note.id)"><strong>{{ value(note.createdByName || note.createdBy) }}</strong><span>{{ value(note.content) }} · {{ date(note.createdAt) }}</span></div></div>
<p v-else class="muted-empty">暂无内部备注</p>
</section>
<section class="history-section">
<section v-if="shows('history')" class="history-section">
<div class="section-heading"><h3>状态历史</h3><span>{{ statusHistory.length }} 条</span></div>
<ol v-if="statusHistory.length" class="timeline"><li v-for="(history, index) in statusHistory" :key="`${history.occurredAt}-${index}`"><span class="timeline-dot" /><div><strong>{{ inboundHistoryActionLabel(history.action) }}</strong><span>{{ inboundStatusLabel(history.fromStatus, inboundProcessStatusLabels) }} → {{ inboundStatusLabel(history.toStatus, inboundProcessStatusLabels) }} · {{ value(history.operatorName || history.operatorId) }}</span><small>{{ date(history.occurredAt) }}<template v-if="history.reason"> · {{ history.reason }}</template></small></div></li></ol>
<p v-else class="muted-empty">暂无状态历史</p>
</section>
<section v-if="publicRemark" class="history-section remark-section"><div class="section-heading"><h3>公开备注</h3></div><p>{{ publicRemark }}</p></section>
<section v-if="shows('remark') && publicRemark" class="history-section remark-section"><div class="section-heading"><h3>公开备注</h3></div><p>{{ publicRemark }}</p></section>
</div>
</template>
-1
View File
@@ -70,4 +70,3 @@ describe('inbound fee API', () => {
expect(second).not.toBe(first)
})
})
-1
View File
@@ -32,4 +32,3 @@ export function createFeeAdjustmentIdempotencyKey() {
if (uuid) return `fee-adjust-${uuid}`
return `fee-adjust-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
}
-1
View File
@@ -10,4 +10,3 @@ export type {
InboundFeeSnapshot,
InboundFeeStatus,
} from './types'
-1
View File
@@ -51,4 +51,3 @@ export interface AdjustPublishedInboundFeeRequest {
additionalAmount: number
reason: string
}
@@ -214,6 +214,11 @@ describe('InboundMeasurementPanel', () => {
'机器编号',
'测量时间',
])
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
expect(source).toContain('.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
expect(source).toContain('.measurement-item__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));')
expect(source).toContain('.measurement-form-grid, .measurement-item__grid, .measurement-item__advanced .measurement-item__grid { grid-template-columns: 1fr; }')
expect(source).toContain('defineExpose({ saveDraft, submitForReview, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })')
})
it('reuses the established first-leg detail surface and status patterns', async () => {
@@ -438,6 +438,8 @@ watch(
)
onBeforeUnmount(releaseImagePreview)
defineExpose({ saveDraft, submitForReview, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })
</script>
<template>
@@ -672,7 +674,8 @@ onBeforeUnmount(releaseImagePreview)
.measurement-section { padding: 22px 20px; border-top: 1px solid var(--cc-border); }
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.measurement-section__heading > div { min-width: 0; }
.measurement-form-grid, .measurement-item__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 15px; }
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }
.measurement-item__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 15px; }
.measurement-form label, .measurement-item label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
.measurement-form :deep(.el-input-number), .measurement-form :deep(.el-input) { width: 100%; }
.measurement-image-field :deep(.el-button) { width: 100%; margin: 0; }
@@ -1,11 +1,45 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it } from 'vitest'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrderDetailView.vue'), 'utf8')
describe('InboundOrderDetailView', () => {
it('maps the compatible route parameter to customer context without a detail-page selector', () => {
it('maps the compatible route parameter to customer context without a detail-page selector', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'dashboard', component: { template: '<div>首页</div>' } },
{ path: '/inbounds', name: 'inbound-orders', component: { template: '<div>入库单列表</div>' } },
{ path: '/inbounds/:inboundId', name: 'inbound-order-detail', component: InboundOrderDetailView },
],
})
await router.push('/inbounds/19001?tenantId=10002')
await router.isReady()
const wrapper = mount(InboundOrderDetailView, {
global: {
plugins: [router, ElementPlus],
stubs: {
InboundWorkflowWorkspace: {
props: ['customerId', 'inboundId'],
template: '<div data-testid="workspace">{{ customerId }}:{{ inboundId }}</div>',
},
},
},
})
expect(wrapper.get('[aria-label="面包屑"]').text()).toContain('首页入库单列表入库单详情')
expect(wrapper.get('[data-testid="workspace"]').text()).toBe('10002:19001')
expect(wrapper.findAll('button').filter((button) => button.text().includes('返回入库单列表'))).toHaveLength(2)
await wrapper.findAll('button').filter((button) => button.text().includes('返回入库单列表')).at(-1)?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('inbound-orders')
expect(router.currentRoute.value.query.tenantId).toBe('10002')
expect(source).toContain('route.query.tenantId')
expect(source).toContain('const customerId = computed')
expect(source).toContain("name: 'inbound-orders'")
@@ -19,5 +53,9 @@ describe('InboundOrderDetailView', () => {
expect(source).toContain('class="surface-panel detail-page"')
expect(source).toContain('class="detail-page-actions"')
expect(source).toContain('返回入库单列表')
expect(source).toContain('class="detail-breadcrumb"')
expect(source).toContain('class="detail-page-footer"')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('@media (max-width: 360px)')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, RefreshCw } from '@lucide/vue'
import { ArrowLeft, ChevronRight, Home } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
@@ -22,10 +22,21 @@ const customerId = computed(() => routeValue(route.query.tenantId))
function backToList() {
void router.push({ name: 'inbound-orders', query: customerId.value ? { tenantId: customerId.value } : undefined })
}
function backToHome() {
void router.push({ name: 'dashboard' })
}
</script>
<template>
<AdminPage as="div" class="business-page inbound-detail-page">
<nav class="detail-breadcrumb" aria-label="面包屑">
<button type="button" @click="backToHome"><Home :size="14" />首页</button>
<ChevronRight :size="14" aria-hidden="true" />
<button type="button" @click="backToList">入库单列表</button>
<ChevronRight :size="14" aria-hidden="true" />
<span aria-current="page">入库单详情</span>
</nav>
<DetailPageLayout class="surface-panel detail-page" aria-label="入库单详情">
<template #header>
<PageToolbar title="入库单详情" description="查看基本信息、货品与测量、费用信息、状态历史及其他记录。">
@@ -46,18 +57,28 @@ function backToList() {
:description="inboundId ? '缺少所属企业信息,请从入库单列表重新打开' : '缺少有效的入库单标识'"
/>
</FormSection>
<footer class="detail-page-footer">
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
</footer>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.inbound-detail-page { min-width: 0; }
.detail-breadcrumb { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 6px; padding: 0 2px; color: var(--cc-muted); font-size: 13px; }
.detail-breadcrumb button { display: inline-flex; min-height: 32px; align-items: center; gap: 5px; padding: 4px 6px; border: 0; border-radius: 4px; background: transparent; color: var(--cc-primary); cursor: pointer; font: inherit; }
.detail-breadcrumb button:hover { background: #f0f7ff; }
.detail-breadcrumb button:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
.detail-page { overflow: hidden; padding: 20px 24px; }
.detail-page-actions { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 12px; }
.detail-page-footer { display: flex; justify-content: flex-start; padding: 20px 0 0; border-top: 1px solid var(--cc-border); }
@media (max-width: 720px) {
.detail-page { padding: 16px; }
.detail-page-actions { width: 100%; align-items: stretch; flex-direction: column; }
.detail-page-actions :deep(.el-button) { width: 100%; min-height: 44px; }
.detail-page-footer :deep(.el-button) { width: 100%; min-height: 44px; }
}
@media (max-width: 360px) { .detail-page { padding: 12px; } }
</style>
@@ -10,12 +10,18 @@ import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import { listPlatformWarehouses } from '@/modules/warehouses'
import { useAuthStore } from '@/core/auth/auth'
import { downloadInboundCartonLabels, getPlatformInbound, listPlatformInbounds } from '../core/api'
import type { InboundOrderDetail, InboundOrderSummary } from '../core/types'
import { downloadInboundCartonLabels, listPlatformInbounds } from '../core/api'
import type { InboundOrderSummary } from '../core/types'
const routerPush = vi.fn()
vi.mock('vue-router', async (importOriginal) => ({
...await importOriginal<typeof import('vue-router')>(),
useRouter: () => ({ push: routerPush }),
}))
vi.mock('../core/api', () => ({
downloadInboundCartonLabels: vi.fn(),
getPlatformInbound: vi.fn(),
listPlatformInbounds: vi.fn(),
}))
@@ -46,13 +52,6 @@ const summary: InboundOrderSummary = {
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00',
}
const detail: InboundOrderDetail = {
summary,
publicRemark: '请优先安排测量',
palletLocations: [{ allocationNo: 1, warehouseId: '20001', palletId: '21001', palletCode: 'P-001', palletCapacityCbm: 2, allocatedVolumeCbm: 1.2, allocatedPackageCount: 6, locationId: '22001', locationCode: 'A-01', reservationNo: 'RES-1', allocationStatus: 'PREPARED' }],
files: [], internalNotes: [], statusHistory: [{ fromStatus: null, toStatus: 'WAIT_MEASURE', action: 'CREATE', reason: null, operatorId: '2', operatorName: '管理员', occurredAt: '2026-08-01T10:00:00' }],
}
enableAutoUnmount(afterEach)
function mountPage() {
@@ -76,7 +75,6 @@ describe('InboundOrdersView', () => {
setActivePinia(createPinia())
useAuthStore().permissions = ['platform:firstleg:inbound:view']
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformInbound).mockResolvedValue(detail)
vi.mocked(downloadInboundCartonLabels).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
vi.mocked(listPlatformTenants).mockResolvedValue({ records: customers, page: 1, size: 200, total: 2, pages: 1 })
vi.mocked(listPlatformWarehouses).mockResolvedValue({
@@ -171,7 +169,7 @@ describe('InboundOrdersView', () => {
}))
})
it('passes the selected backend tab and opens an aggregate read-only detail', async () => {
it('passes the selected backend tab and routes view to the independent detail page', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
toolbar.vm.$emit('search')
@@ -183,9 +181,12 @@ describe('InboundOrdersView', () => {
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ tab: 'WAIT_REVIEW', page: 1 }))
;[...document.querySelectorAll<HTMLButtonElement>('button')].filter((button) => button.textContent?.trim() === '查看').at(-1)?.click()
await flushPromises()
expect(getPlatformInbound).toHaveBeenCalledWith('10002', '19001')
expect(document.body.textContent).toContain('托盘与库位')
expect(document.body.textContent).toContain('请优先安排测量')
expect(routerPush).toHaveBeenCalledWith({
name: 'inbound-order-detail',
params: { inboundId: '19001' },
query: { tenantId: '10002' },
})
expect(wrapper.find('[aria-label="入库单详情抽屉"]').exists()).toBe(false)
wrapper.unmount()
})
@@ -6,21 +6,16 @@ import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Printer, RefreshCw, Slider
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses } from '@/modules/warehouses'
import { downloadInboundCartonLabels, getPlatformInbound, listPlatformInbounds } from '../core/api'
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
import type { InboundListTab, InboundOrderDetail, InboundOrderSummary } from '../core/types'
import { downloadInboundCartonLabels, listPlatformInbounds } from '../core/api'
import type { InboundListTab, InboundOrderSummary } from '../core/types'
import { inboundFeeStatusLabels, inboundLoadingStatusLabels, inboundOrderStatusLabels, inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
type FilterModel = QueryModel & {
@@ -47,11 +42,6 @@ const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<InboundOrderDetail | null>(null)
const detailTarget = ref<InboundOrderSummary | null>(null)
const printingInboundId = ref('')
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
@@ -66,7 +56,6 @@ const warehousesLoading = ref(false)
const warehousesUnavailable = ref(false)
const showAdvancedFilters = ref(false)
let loadRequestId = 0
let detailRequestId = 0
let customerRequestId = 0
let warehouseRequestId = 0
let disposed = false
@@ -293,35 +282,7 @@ function applyFilters() {
else page.value = 1
}
function resetDetail() {
detailRequestId += 1
detail.value = null
detailTarget.value = null
detailError.value = null
detailLoading.value = false
}
async function openDetail(row: InboundOrderSummary) {
const requestId = ++detailRequestId
detailOpen.value = true
detailTarget.value = row
detailLoading.value = true
detailError.value = null
detail.value = null
try {
const response = await getPlatformInbound(row.tenantId, row.id)
if (disposed || requestId !== detailRequestId) return
detail.value = response
} catch (error) {
if (disposed || requestId !== detailRequestId) return
detailError.value = getApiErrorDetails(error, '入库单详情加载失败')
} finally {
if (!disposed && requestId === detailRequestId) detailLoading.value = false
}
}
function closeDetail() { resetDetail() }
function openWorkspace(row: InboundOrderSummary) {
function openDetail(row: InboundOrderSummary) {
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: { tenantId: row.tenantId } })
}
async function printCartonLabels(row: InboundOrderSummary) {
@@ -372,8 +333,6 @@ function resetFilters() {
filters.value = { ...filters.value, customerId: '', warehouseId: '', deliveryWarehouseId: '' }
warehouseOptions.value = []
warehousesUnavailable.value = false
resetDetail()
detailOpen.value = false
applyFilters()
}
@@ -397,14 +356,12 @@ watch(() => filters.value.customerId, (customerId) => {
if (customerId === selectedCustomerId.value) return
selectedCustomerId.value = customerId
filters.value = { ...filters.value, warehouseId: '', deliveryWarehouseId: '' }
resetDetail()
detailOpen.value = false
void loadWarehouseOptions()
})
watch(tab, () => { if (page.value === 1) void loadInbounds(); else page.value = 1 })
watch([page, pageSize], () => { void loadInbounds() })
onMounted(() => { void loadCustomers(); void loadInbounds() })
onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId += 1; customerRequestId += 1; warehouseRequestId += 1 })
onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId += 1; warehouseRequestId += 1 })
</script>
<template>
@@ -454,7 +411,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
</header>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button link type="primary" @click="openDetail(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
<template #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template>
<template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template>
@@ -499,15 +456,6 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
</section>
</ListPageLayout>
<AppDrawer v-model="detailOpen" :title="detail ? `入库单详情 · ${detail.summary.inboundCode || detail.summary.id}` : '入库单详情'" size="min(720px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
<DetailPageLayout aria-label="入库单详情抽屉">
<section v-if="detailError" class="detail-error" role="alert"><strong>入库单详情暂时无法加载</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /><el-button type="primary" :disabled="!detailTarget" @click="detailTarget && openDetail(detailTarget)"><RefreshCw :size="16" />重新加载</el-button></section>
<div v-else-if="detail" class="detail-content">
<FormSection title="入库概览"><InboundSummaryPanel :summary="detail.summary" :public-remark="detail.publicRemark" /></FormSection>
<FormSection title="资源与状态记录" :bordered="false"><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="null" /></FormSection>
</div>
</DetailPageLayout>
</AppDrawer>
</AdminPage>
</template>
@@ -535,11 +483,6 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
.primary-cell, .status-stack { display: grid; min-width: 0; justify-items: start; gap: 4px; }
.primary-cell strong, .primary-cell span, .status-stack span, .status-stack small { overflow-wrap: anywhere; }
.primary-cell span, .status-stack span, .status-stack small { color: var(--cc-muted); font-size: 12px; }
.detail-content { display: grid; gap: 24px; min-width: 0; }
.detail-error { display: flex; min-height: 240px; align-items: flex-start; justify-content: center; flex-direction: column; }
.detail-error strong { color: #b42318; font-size: 16px; }
.detail-error p { margin: 7px 0; color: #172033; font-size: 13px; }
.detail-error :deep(.el-button) { margin-top: 14px; }
@media (max-width: 900px) { .inbound-tabs { width: 100%; } }
@media (max-width: 720px) { .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 0 10px; } .inbound-stats-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .inbound-stats-row .total-info { justify-content: flex-start; } }
@media (max-width: 360px) { .inbound-tab { padding-inline: 12px; } .row-actions { align-items: flex-start; flex-direction: column; } }
@@ -58,6 +58,8 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.text()).toContain('通过并完成入库')
expect(wrapper.text()).not.toContain('确认费用')
expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001')
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
expect(source).toContain('defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })')
})
it('shows the rejected release action only when the permission and rejected state exist', async () => {
@@ -207,6 +207,8 @@ watch(() => [customerId.value, props.inboundId], () => {
onMounted(() => {
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
})
defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })
</script>
<template>