This commit is contained in:
2026-08-04 21:01:39 +08:00
parent 88bd939170
commit 5cb1ca307d
77 changed files with 9914 additions and 131 deletions
@@ -0,0 +1,61 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/components/InboundWorkflowWorkspace.vue'), 'utf8')
describe('InboundWorkflowWorkspace', () => {
it('assembles every phase-two inbound domain behind one server-backed workspace', () => {
expect(source).toContain('InboundSummaryPanel')
expect(source).toContain('InboundHistoryPanel')
expect(source).toContain('InboundMeasurementPanel')
expect(source).toContain('WeightOcrPanel')
expect(source).toContain('MeasurementReviewPanel')
expect(source).toContain('InboundFeePanel')
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="托盘与记录"')
})
it('uses the established first-leg detail density without nested framed surfaces', () => {
expect(source).toContain('padding: 20px 24px')
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
expect(source).not.toContain('border-radius: 10px')
expect(source).not.toContain('<el-tag')
})
it('refreshes the aggregate and remounts domains after manual or child refresh', () => {
expect(source).toContain('function handleDomainChanged()')
expect(source).toContain('async function refreshWorkspace()')
expect(source).toContain('revision.value += 1')
expect(source).toContain('void refreshWorkspace()')
expect(source).toContain('@click="refreshWorkspace"')
expect(source).toContain('@changed="handleDomainChanged"')
})
it('uses customer context in the page layer and maps it to compatible domain APIs', () => {
expect(source).toContain('customerId: string')
expect(source).toContain('getPlatformInbound(props.customerId, props.inboundId)')
expect(source).toContain(':tenant-id="customerId"')
expect(source).toContain('缺少所属企业信息')
expect(source).not.toContain('请选择企业')
expect(source).not.toContain('租户')
})
it('does not mount the review domain without its required measurement data permission', () => {
expect(source).toContain("access.can('platform:firstleg:inbound:measure:view')")
expect(source).toContain('v-if="canViewMeasurement"')
expect(source).toContain('无测量审核查看权限')
})
it('keeps pallet and location results read-only in the overview', () => {
expect(source).toContain(':pallet-locations="detail.palletLocations"')
expect(source).toContain('label="托盘与记录"')
expect(source).not.toContain('reserveLocation')
expect(source).not.toContain('allocatePallet')
})
})
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { 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'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getPlatformInbound } from '../core/api'
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
import type { InboundOrderDetail } from '../core/types'
import { InboundMeasurementPanel } from '../measurement'
import { WeightOcrPanel } from '../weight'
import { MeasurementReviewPanel } from '../review'
import { InboundFeePanel } from '../fee'
const props = defineProps<{
customerId: string
inboundId: string
}>()
const emit = defineEmits<{
changed: []
}>()
const access = useAccessPreviewStore()
const activeTab = ref('overview')
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const detail = ref<InboundOrderDetail | null>(null)
const revision = ref(0)
let requestSerial = 0
let disposed = false
const title = computed(() => detail.value?.summary.inboundCode || props.inboundId)
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const statusLabel = computed(() => {
const status = detail.value?.summary.processStatus || ''
return ({
WAIT_MEASURE: '待测量',
MEASURING: '测量中',
MEASURE_PENDING: '待审核',
MEASURE_REJECTED: '测量已驳回',
WAIT_PRICING: '待计费',
FEE_CONFIRMING: '费用确认中',
INBOUNDING: '入库处理中',
INBOUNDED: '已入库',
VOIDED: '已作废',
} as Record<string, string>)[status] || status || '未知状态'
})
async function loadDetail() {
const serial = ++requestSerial
if (!props.customerId || !props.inboundId) {
detail.value = null
loadError.value = null
loading.value = false
return
}
loading.value = true
loadError.value = null
try {
const response = await getPlatformInbound(props.customerId, props.inboundId)
if (disposed || serial !== requestSerial) return
detail.value = response
} catch (error) {
if (disposed || serial !== requestSerial) return
detail.value = null
loadError.value = getApiErrorDetails(error, '入库单详情加载失败')
} finally {
if (!disposed && serial === requestSerial) loading.value = false
}
}
async function refreshWorkspace() {
revision.value += 1
await loadDetail()
}
function handleDomainChanged() {
void refreshWorkspace()
emit('changed')
}
watch(
() => [props.customerId, props.inboundId],
() => {
activeTab.value = 'overview'
detail.value = null
loadError.value = null
revision.value += 1
void loadDetail()
},
{ immediate: true },
)
onBeforeUnmount(() => {
disposed = true
requestSerial += 1
})
</script>
<template>
<section class="inbound-workspace" :aria-busy="loading">
<header class="workspace-header">
<div>
<div class="workspace-title-row">
<h2>入仓码:{{ title }}</h2>
<StatusBadge v-if="detail" :status="statusLabel" />
</div>
<p>按基本信息、货物测量、审核、费用和托盘库位顺序处理,所有结果以服务端最新状态为准。</p>
</div>
<el-button :loading="loading" :disabled="!customerId || !inboundId" @click="refreshWorkspace">
<RefreshCw :size="16" />刷新
</el-button>
</header>
<LoadErrorState
v-if="loadError"
class="workspace-state"
title="入库作业详情暂时无法加载"
:message="loadError.message"
@retry="loadDetail"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<div v-else-if="loading && !detail" class="workspace-state workspace-skeleton" aria-label="正在加载入库作业详情">
<el-skeleton :rows="8" animated />
</div>
<el-empty v-else-if="!detail" class="workspace-state" description="缺少所属企业信息,无法查看入库作业详情" />
<el-tabs v-else v-model="activeTab" class="workflow-tabs">
<el-tab-pane label="基本信息" name="overview">
<InboundSummaryPanel :summary="detail.summary" />
</el-tab-pane>
<el-tab-pane label="货物与测量" name="measurement" lazy>
<div class="measurement-workflow">
<InboundMeasurementPanel
:key="`measurement-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
<WeightOcrPanel
:key="`weight-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
</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"
/>
<el-alert
v-else
title="无测量审核查看权限"
description="当前账号缺少测量查看权限,无法读取审核所需的原单、箱规和称重数据。"
type="warning"
:closable="false"
show-icon
/>
</el-tab-pane>
<el-tab-pane label="费用快照" name="fee" lazy>
<InboundFeePanel
:key="`fee-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
</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="detail.publicRemark"
/>
</el-tab-pane>
</el-tabs>
</section>
</template>
<style scoped>
.inbound-workspace { min-width: 0; }
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 18px; 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; }
.workspace-skeleton { padding: 0; }
.workflow-tabs { min-width: 0; padding: 0 24px 24px; }
.workflow-tabs :deep(.el-tabs__header) { margin-bottom: 16px; }
.workflow-tabs :deep(.el-tabs__item) { min-height: 48px; }
.measurement-workflow { display: grid; min-width: 0; gap: 28px; }
@media (max-width: 720px) {
.workspace-header { align-items: stretch; flex-direction: column; padding: 16px; }
.workspace-header :deep(.el-button) { width: 100%; min-height: 44px; }
.workspace-state { margin: 16px; }
.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; }
}
</style>