This commit is contained in:
2026-08-17 18:00:42 +08:00
parent c7334b2cf8
commit ec3dbb3fe4
23 changed files with 407 additions and 999 deletions
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
approvePlatformUnifiedOrder,
batchApprovePlatformUnifiedOrders,
addPlatformUnifiedOrderNote,
getPlatformUnifiedOrder,
listPlatformUnifiedOrders,
@@ -97,6 +98,23 @@ describe('unified orders API contract', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/first-leg/90001/approve', undefined, { params: { tenantId: '208' } })
})
it('uses the tenant-scoped batch approval contract and preserves long IDs', async () => {
vi.mocked(http.post).mockResolvedValue({ data: {
total: 2, successCount: 1, failureCount: 1,
successOrderIds: ['2089223263733518337'],
failedOrders: [{ orderId: '2089223263733518338', code: 'A0409', message: '订单状态已变化' }],
} })
await expect(batchApprovePlatformUnifiedOrders('2089176869176389634', [
'2089223263733518337', '2089223263733518338',
])).resolves.toMatchObject({ successCount: 1, failureCount: 1 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/orders/first-leg/batch-approve',
{ orderIds: ['2089223263733518337', '2089223263733518338'] },
{ params: { tenantId: '2089176869176389634' } },
)
})
it('writes platform internal notes through the unified order boundary', async () => {
vi.mocked(http.post).mockResolvedValue({ data: '91001' })
await expect(addPlatformUnifiedOrderNote('208', '90001', '已确认船期')).resolves.toBe('91001')
@@ -388,6 +388,22 @@ export function approvePlatformUnifiedOrder(tenantId: string, orderId: string) {
).then(unwrapApiResponse)
}
export interface UnifiedOrderBatchApprovalResult {
total: number
successCount: number
failureCount: number
successOrderIds: Array<number | string>
failedOrders: Array<{ orderId: number | string; code: string; message: string }>
}
export function batchApprovePlatformUnifiedOrders(tenantId: string, orderIds: string[]) {
return http.post<ApiEnvelope<UnifiedOrderBatchApprovalResult>, ApiEnvelope<UnifiedOrderBatchApprovalResult>>(
'/platform/v1/orders/first-leg/batch-approve',
{ orderIds },
{ params: { tenantId } },
).then(unwrapApiResponse)
}
export function addPlatformUnifiedOrderNote(tenantId: string, orderId: string, content: string) {
return http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(
`${unifiedOrdersApiPath}/${orderId}/internal-notes`,
@@ -83,15 +83,17 @@ describe('UnifiedOrdersView contract', () => {
it('covers real list states and keeps operation column first and left-fixed', () => {
expect(source).toContain("{ key: 'actions', label: '操作', width: 300, fixed: 'left'")
expect(source).not.toContain("{ key: 'selection', type: 'selection'")
expect(source).toContain("key: 'selection', type: 'selection'")
expect(source).not.toContain('批量审核接口待接入')
expect(source).not.toContain('@selection-change="handleSelectionChange"')
expect(source).toContain('@selection-change="handleApprovalSelectionChange"')
expect(source).toContain('batchApprovePlatformUnifiedOrders')
expect(source).toContain('批量审核通过')
expect(source).toContain('title="订单列表暂时无法加载"')
expect(source).toContain('empty-text="暂无符合条件的订单"')
expect(source).toContain(':loading="loading"')
expect(source).toContain('v-model:page="page"')
expect(source).toContain('v-model:page-size="pageSize"')
const columnSource = source.slice(source.indexOf('const columns:'), source.indexOf('function emptyFilters'))
const columnSource = source.slice(source.indexOf('const columns ='), source.indexOf('function emptyFilters'))
const expectedColumnKeys = [
'actions', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
'destinationWarehouseName', 'totalPackageCount', 'measurementReviewStatus', 'actualGrossWeightKg',
@@ -218,9 +220,29 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain("value.replaceAll('_', ' ')")
})
it('opens the measurement image preview at the clicked image index', () => {
expect(source).toContain('v-for="(url, index) in measurementImageUrls"')
expect(source).toContain(':initial-index="index"')
it('binds dimension and weight evidence to their formal server records without mixing galleries', () => {
expect(source).toContain('<el-table :data="measurementDetail?.items || []" row-key="id"')
expect(source).toContain('label="尺寸图片"')
expect(source).toContain(':file-id="row.intelligentImageFileId"')
expect(source).toContain('openMeasurementEvidence(row.intelligentImageFileId')
expect(source).toContain('v-for="weightResult in measurementDetail.weightResults"')
expect(source).toContain(':file-id="weightResult.fileId"')
expect(source).toContain('openMeasurementEvidence(weightResult.fileId')
expect(source).toContain('measurementDetail?.actualGrossWeightKg')
expect(source).toContain('weightResult.grossWeightKg')
expect(source).toContain('weightResult.netWeightKg')
expect(source).toContain("weightResult.palletType || '未记录类型'")
expect(source).not.toContain('weightResult.palletCode')
expect(source).not.toContain('measurementImageUrls')
expect(source).not.toContain('const fileIds = [...measurementDetail.value.items')
})
it('keeps authenticated evidence loading, original preview, permission and error states', () => {
expect(source).toContain('<MeasurementEvidenceThumbnail')
expect(source).toContain("downloadWeightImage(String(summary.tenantId), String(summary.inboundId), fileId)")
expect(source).toContain('measurementPreviewError')
expect(source).toContain('测量原图暂时无法读取')
expect(source).toContain("can('platform:firstleg:inbound:measure:view')")
})
it('shows parent-child markers only for related rows and keeps the aggregate in detail', () => {
@@ -289,7 +311,7 @@ describe('UnifiedOrdersView contract', () => {
it('matches the prototype table grid and distinguishes container identifiers', () => {
expect(source).toContain('row-key="orderId" border stripe')
expect(source).toContain('<el-table :data="detail.order.cargoes" border stripe')
expect(source.match(/<el-table :data="measurementDetail\?\.items \|\| \[\]" border stripe/g)).toHaveLength(2)
expect(source.match(/<el-table :data="measurementDetail\?\.items \|\| \[\]"[^>]*border stripe/g)).toHaveLength(2)
expect(source).toContain('<el-table :data="detail.order.cargoes" border stripe empty-text="暂无客户下单数据"')
expect(source).toContain('--el-table-border-color: #e5e7eb')
expect(source).toContain('--el-table-row-hover-bg-color: #f3f7fb')
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch, type Component } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
approvePlatformUnifiedOrder,
batchApprovePlatformUnifiedOrders,
addPlatformUnifiedOrderNote,
listPlatformUnifiedOrders,
unifiedOrderStatusOptions,
@@ -51,6 +52,7 @@ import {
downloadWeightImage,
getInboundMeasurement,
InboundMeasurementDialog,
MeasurementEvidenceThumbnail,
type InboundMeasurementDetail,
} from '@/modules/inbounds'
import {
@@ -103,6 +105,8 @@ const activeDetailTab = ref('basic')
const approvalOpen = ref(false)
const approvalLoadingId = ref('')
const approvalSaving = ref(false)
const batchApprovalSaving = ref(false)
const selectedApprovalOrders = ref<UnifiedOrderResource[]>([])
const approvalOrder = ref<UnifiedOrderResource | null>(null)
const noteOpen = ref(false)
const noteSaving = ref(false)
@@ -112,14 +116,21 @@ const relationOpen = ref(false)
const relationOrder = ref<UnifiedOrderResource | null>(null)
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
const measurementLoading = ref(false)
const measurementError = ref<ApiErrorDetails | null>(null)
const measurementLoadedKey = ref('')
const measurementImageUrls = ref<string[]>([])
const measurementPreviewOpen = ref(false)
const measurementPreviewLoading = ref(false)
const measurementPreviewError = ref<ApiErrorDetails | null>(null)
const measurementPreviewUrl = ref('')
const measurementPreviewTitle = ref('测量图片')
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
const measurementDialogOpen = ref(false)
const smartLoadingOpen = ref(false)
const measurementOrder = ref<UnifiedOrderResource | null>(null)
let detailLoadingKey = ''
let detailRequestSerial = 0
let measurementRequestSerial = 0
let measurementPreviewRequestSerial = 0
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const appliedFilters = ref<FilterModel>(emptyFilters())
@@ -208,8 +219,12 @@ const queryFields = computed(() => showAdvancedFilters.value
? allQueryFields.value
: primaryQueryFields.value)
const columns: DataTableColumn[] = [
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
...(can('platform:order:approve') ? [{
key: 'selection', type: 'selection', width: 48, fixed: 'left',
selectable: (row: UnifiedOrderResource) => canApprove(row) && !batchApprovalSaving.value,
} satisfies DataTableColumn] : []),
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, slotName: 'inboundCode' },
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 130, slotName: 'mainStatus' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
@@ -230,7 +245,7 @@ const columns: DataTableColumn[] = [
{ key: 'latestInternalNote', prop: 'latestInternalNote', label: '内部备注', minWidth: 180, slotName: 'field', defaultVisible: false },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 150, slotName: 'field', defaultVisible: false },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150, slotName: 'field' },
]
])
function emptyFilters(): FilterModel {
return {
@@ -439,30 +454,53 @@ function measurementTotals(rows: Record<string, unknown>[], customerSource = fal
}
}
function revokeMeasurementImages() {
measurementImageUrls.value.forEach((url) => URL.revokeObjectURL(url))
measurementImageUrls.value = []
function releaseMeasurementPreview() {
measurementPreviewRequestSerial += 1
if (measurementPreviewUrl.value) URL.revokeObjectURL(measurementPreviewUrl.value)
measurementPreviewUrl.value = ''
measurementPreviewLoading.value = false
measurementPreviewError.value = null
}
async function openMeasurementEvidence(fileId: string | null, title: string) {
const summary = detail.value?.summary
if (!fileId || !summary?.inboundId) return
releaseMeasurementPreview()
const serial = ++measurementPreviewRequestSerial
measurementPreviewTitle.value = title
measurementPreviewOpen.value = true
measurementPreviewLoading.value = true
try {
const blob = await downloadWeightImage(String(summary.tenantId), String(summary.inboundId), fileId)
if (serial !== measurementPreviewRequestSerial) return
measurementPreviewUrl.value = URL.createObjectURL(blob)
} catch (error) {
if (serial !== measurementPreviewRequestSerial) return
measurementPreviewError.value = getApiErrorDetails(error, '测量原图读取失败,请稍后重试')
} finally {
if (serial === measurementPreviewRequestSerial) measurementPreviewLoading.value = false
}
}
async function loadMeasurementDetails() {
const summary = detail.value?.summary
if (!summary?.inboundId || !can('platform:firstleg:inbound:view')) return
if (!summary?.inboundId || !can('platform:firstleg:inbound:measure:view')) return
const key = `${summary.tenantId}:${summary.inboundId}`
if (measurementLoadedKey.value === key || measurementLoading.value) return
const serial = ++measurementRequestSerial
measurementLoading.value = true
measurementError.value = null
try {
measurementDetail.value = await getInboundMeasurement(String(summary.tenantId), String(summary.inboundId))
const response = await getInboundMeasurement(String(summary.tenantId), String(summary.inboundId))
if (serial !== measurementRequestSerial) return
measurementDetail.value = response
measurementLoadedKey.value = key
revokeMeasurementImages()
const fileIds = [...measurementDetail.value.items.map((item) => item.intelligentImageFileId), ...measurementDetail.value.weightResults.map((item) => item.fileId)]
.filter((value): value is string => Boolean(value))
const blobs = await Promise.all(fileIds.map((fileId) => downloadWeightImage(String(summary.tenantId), String(summary.inboundId), fileId).catch(() => null)))
measurementImageUrls.value = blobs.filter((blob): blob is Blob => Boolean(blob)).map((blob) => URL.createObjectURL(blob))
} catch {
} catch (error) {
if (serial !== measurementRequestSerial) return
measurementDetail.value = null
revokeMeasurementImages()
measurementError.value = getApiErrorDetails(error, '测量数据加载失败,请稍后重试')
} finally {
measurementLoading.value = false
if (serial === measurementRequestSerial) measurementLoading.value = false
}
}
@@ -584,6 +622,7 @@ async function loadWarehouses() {
}
async function loadOrders() {
selectedApprovalOrders.value = []
loading.value = true
loadError.value = null
try {
@@ -649,6 +688,51 @@ function openMeasurement(row: UnifiedOrderResource) {
measurementDialogOpen.value = true
}
function handleApprovalSelectionChange(rows: UnifiedOrderResource[]) {
selectedApprovalOrders.value = rows.filter(canApprove)
}
async function batchApproveOrders() {
const selected = [...selectedApprovalOrders.value]
if (!selected.length || batchApprovalSaving.value) return
batchApprovalSaving.value = true
try {
await ElMessageBox.confirm(
`确认批量审核通过已勾选的 ${selected.length} 个订单吗?审核后订单将进入待测量。`,
'批量审核订单',
{ confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
)
const groups = new Map<string, UnifiedOrderResource[]>()
selected.forEach((order) => {
const tenantId = String(order.tenantId)
groups.set(tenantId, [...(groups.get(tenantId) || []), order])
})
let successCount = 0
let failureCount = 0
const failures: string[] = []
for (const [tenantId, orders] of groups) {
try {
const result = await batchApprovePlatformUnifiedOrders(tenantId, orders.map((order) => String(order.orderId)))
successCount += result.successCount
failureCount += result.failureCount
failures.push(...result.failedOrders.map((failure) => failure.message))
} catch (error) {
failureCount += orders.length
failures.push(getApiErrorDetails(error, '该客户订单批量审核失败').message)
}
}
await loadOrders()
const reason = [...new Set(failures.filter(Boolean))].slice(0, 3).join(';')
if (!failureCount) ElMessage.success(`已批量审核通过 ${successCount} 个订单`)
else if (successCount) ElMessage.warning(`批量审核完成:成功 ${successCount} 个,失败 ${failureCount} 个${reason ? `。${reason}` : ''}`)
else ElMessage.error(`批量审核失败:${failureCount} 个订单未通过${reason ? `。${reason}` : ''}`)
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '批量审核失败').message)
} finally {
batchApprovalSaving.value = false
}
}
function handleMeasurementUpdated() {
void loadOrders()
}
@@ -714,9 +798,12 @@ async function loadDetail(force = false) {
if (!force && (detailLoadingKey === requestKey || detail.value?.summary.orderId === orderId.value && String(detail.value.summary.tenantId) === tenantId)) return
const serial = ++detailRequestSerial
detailLoadingKey = requestKey
measurementRequestSerial += 1
measurementDetail.value = null
measurementError.value = null
measurementLoadedKey.value = ''
revokeMeasurementImages()
measurementPreviewOpen.value = false
releaseMeasurementPreview()
detailLoading.value = true
detailError.value = null
try {
@@ -747,11 +834,18 @@ watch(detailRouteKey, (value) => {
detailLoading.value = false
}
}, { immediate: true })
watch(measurementPreviewOpen, (open) => {
if (!open) releaseMeasurementPreview()
})
onMounted(() => {
void loadCustomers()
void loadWarehouses()
if (!orderId.value) void loadOrders()
})
onBeforeUnmount(() => {
measurementRequestSerial += 1
releaseMeasurementPreview()
})
</script>
<template>
@@ -913,15 +1007,20 @@ onMounted(() => {
/>
</template>
<template v-else-if="activeDetailTab === 'measurement'">
<div class="panel-title"><div><h2>测量数据</h2><p>{{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p></div><Ruler :size="19" /></div>
<section class="measurement-section">
<div class="panel-title"><div><h2>测量数据</h2><p>{{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p></div><Ruler :size="19" /></div>
<el-alert v-if="!can('platform:firstleg:inbound:measure:view')" title="当前账号缺少测量查看权限" type="warning" :closable="false" show-icon />
<LoadErrorState v-else-if="measurementError" title="测量数据暂时无法加载" :message="measurementError.message" :loading="measurementLoading" @retry="loadMeasurementDetails">
<template #details><ApiErrorMeta :details="measurementError" /></template>
</LoadErrorState>
<template v-else>
<section class="measurement-section">
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.customer" @click="toggleMeasurementSection('customer')"><h3>一、客户下单数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.customer" :size="18" /><ChevronDown v-else :size="18" /></button>
<div v-if="!measurementCollapsed.customer">
<div class="detail-table-wrap"><el-table :data="detail.order.cargoes" border stripe empty-text="暂无客户下单数据"><el-table-column v-for="column in customerMeasurementColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key, true)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key, true) }}</template></template></el-table-column></el-table></div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}</span></div>
</div>
</section>
<section class="measurement-section">
<section class="measurement-section">
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.outer" @click="toggleMeasurementSection('outer')"><h3>二、外箱规数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.outer" :size="18" /><ChevronDown v-else :size="18" /></button>
<div v-if="!measurementCollapsed.outer">
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="measurementDetail?.items || []" border stripe empty-text="暂无外箱规数据"><el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
@@ -931,12 +1030,55 @@ onMounted(() => {
<section class="measurement-section">
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.actual" @click="toggleMeasurementSection('actual')"><h3>三、实际测量数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementDetail?.actualPackageCount ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}</span><span>总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span><span>总材积重:{{ formatNumber(measurementDetail?.volumeWeightKg) }}</span><span>重量体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.actual" :size="18" /><ChevronDown v-else :size="18" /></button>
<div v-if="!measurementCollapsed.actual">
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="measurementDetail?.items || []" border stripe empty-text="暂无实际测量数据"><el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
<div v-loading="measurementLoading" class="detail-table-wrap">
<el-table :data="measurementDetail?.items || []" row-key="id" border stripe empty-text="暂无实际测量数据">
<el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120">
<template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template>
</el-table-column>
<el-table-column label="尺寸图片" min-width="148">
<template #default="{ row }">
<MeasurementEvidenceThumbnail
:customer-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:file-id="row.intelligentImageFileId"
:alt="`测量明细 ${row.itemNo || row.id} 的尺寸图片`"
empty-text="未绑定尺寸图片"
@open="openMeasurementEvidence(row.intelligentImageFileId, `尺寸图片 · ${row.itemNo || row.id}`)"
/>
</template>
</el-table-column>
</el-table>
</div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementDetail?.actualPackageCount ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}</span><span>总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span><span>总材积:{{ formatNumber(measurementDetail?.volumeWeightKg) }}</span></div>
<div class="measurement-media"><h4>测量图片</h4><div v-if="measurementImageUrls.length" class="measurement-media-grid"><el-image v-for="(url, index) in measurementImageUrls" :key="url" :src="url" fit="contain" :preview-src-list="measurementImageUrls" :initial-index="index" class="measurement-media-image" /></div><el-empty v-else description="暂无测量图片" :image-size="48" /><h4>测量视频</h4><el-empty description="暂无测量视频" :image-size="48" /></div>
<section class="weight-evidence" aria-label="称重图片与服务端确认重量">
<div class="weight-evidence__heading">
<div><h4>称重图片与确认重量</h4><p>称重图片独立展示;重量值直接采用服务端确认结果。</p></div>
<dl><dt>实际总毛重</dt><dd>{{ formatNumber(measurementDetail?.actualGrossWeightKg) }} kg</dd></dl>
</div>
<div v-if="measurementDetail?.weightResults.length" class="weight-evidence-grid">
<article v-for="weightResult in measurementDetail.weightResults" :key="weightResult.id" class="weight-evidence-card">
<MeasurementEvidenceThumbnail
:customer-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:file-id="weightResult.fileId"
:alt="`称重结果 ${weightResult.id} 的称重图片`"
empty-text="未绑定称重图片"
@open="openMeasurementEvidence(weightResult.fileId, `称重图片 · ${weightResult.palletType || '未记录类型'}`)"
/>
<dl>
<div><dt>托盘类型</dt><dd>{{ weightResult.palletType || '未记录类型' }}</dd></div>
<div><dt>服务端确认毛重</dt><dd>{{ formatNumber(weightResult.grossWeightKg) }} kg</dd></div>
<div><dt>托盘皮重</dt><dd>{{ formatNumber(weightResult.palletTareWeightKg) }} kg</dd></div>
<div><dt>货物重量</dt><dd>{{ formatNumber(weightResult.netWeightKg) }} kg</dd></div>
</dl>
</article>
</div>
<el-empty v-else description="暂无称重图片与确认结果" :image-size="48" />
</section>
</div>
</section>
</template>
</section>
</template>
</template>
<template v-else>
<div class="panel-title"><div><h2>内部备注</h2><p>仅平台内部可见</p></div><el-button v-if="can('platform:order:note')" type="primary" size="small" @click="openNoteDialog()"><StickyNote :size="15" />添加备注</el-button></div>
@@ -987,9 +1129,10 @@ onMounted(() => {
<template #default>
<div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表">
<template v-if="can('platform:firstleg:smart-loading:list')" #toolbar-actions>
<el-button type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleApprovalSelectionChange">
<template #toolbar-actions>
<el-button v-if="can('platform:order:approve')" type="success" :loading="batchApprovalSaving" :disabled="loading || !selectedApprovalOrders.length" @click="batchApproveOrders"><CircleCheck :size="16" />批量审核通过<span v-if="selectedApprovalOrders.length">({{ selectedApprovalOrders.length }})</span></el-button>
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
</template>
<template #actions="{ row }">
<div class="row-actions">
@@ -1054,6 +1197,21 @@ onMounted(() => {
<el-alert title="确认订单资料无误后审核通过,订单将进入待测量阶段。" type="info" :closable="false" show-icon />
</AppDialog>
<AppDialog
v-model="measurementPreviewOpen"
:title="measurementPreviewTitle"
width="min(920px, calc(100vw - 28px))"
:loading="measurementPreviewLoading"
:show-footer="false"
destroy-on-close
>
<LoadErrorState v-if="measurementPreviewError" title="测量原图暂时无法读取" :message="measurementPreviewError.message">
<template #details><ApiErrorMeta :details="measurementPreviewError" /></template>
</LoadErrorState>
<img v-else-if="measurementPreviewUrl" class="measurement-original-preview" :src="measurementPreviewUrl" :alt="measurementPreviewTitle" />
<el-empty v-else-if="!measurementPreviewLoading" description="该记录未返回可预览的图片" :image-size="56" />
</AppDialog>
<InboundMeasurementDialog
v-if="measurementOrder?.inboundId"
v-model="measurementDialogOpen"
@@ -1148,10 +1306,20 @@ onMounted(() => {
.measurement-total { display: flex; min-height: 42px; align-items: center; justify-content: flex-end; gap: 24px; padding: 9px 14px; border: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
.measurement-total { border-top: 0; border-radius: 0 0 7px 7px; }
.measurement-total strong { margin-right: auto; color: var(--cc-text); font-size: 13px; }
.measurement-media { margin-top: 20px; }
.measurement-media h4 { margin: 18px 0 10px; color: var(--cc-text); font-size: 14px; }
.measurement-media-grid { display: flex; flex-wrap: wrap; gap: 12px; }
.measurement-media-image { width: 110px; height: 90px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
.weight-evidence { margin-top: 20px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fbfcfe; }
.weight-evidence__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 14px; }
.weight-evidence__heading h4 { margin: 0; color: var(--cc-text); font-size: 14px; }
.weight-evidence__heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; }
.weight-evidence__heading dl { margin: 0; text-align: right; }
.weight-evidence__heading dt { color: var(--cc-muted); font-size: 11px; }
.weight-evidence__heading dd { margin: 4px 0 0; color: var(--cc-text); font-size: 16px; font-weight: 700; }
.weight-evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.weight-evidence-card { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.weight-evidence-card > dl { display: grid; gap: 7px; margin: 0; padding: 12px; }
.weight-evidence-card > dl > div { display: flex; min-width: 0; justify-content: space-between; gap: 12px; }
.weight-evidence-card dt { color: var(--cc-muted); font-size: 11px; }
.weight-evidence-card dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 12px; font-weight: 650; text-align: right; overflow-wrap: anywhere; }
.measurement-original-preview { display: block; width: 100%; max-height: min(72vh, 760px); object-fit: contain; border-radius: 8px; background: #f2f4f7; }
.history-list, .notes-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.history-list li { position: relative; display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 12px; padding-bottom: 20px; }
.history-list li:not(:last-child)::after { position: absolute; top: 15px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); content: ''; }
@@ -1167,10 +1335,6 @@ onMounted(() => {
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 960px) { .detail-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .detail-overview article:nth-child(2) { border-right: 0; } .detail-overview article:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); } .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: auto minmax(0, 1fr) auto; } .order-mark { display: none; } }
</style>
<style>
.inbound-task-dialog { max-height: 92vh; overflow: hidden; }
.inbound-task-dialog .el-dialog__body { max-height: calc(92vh - 62px); overflow: auto; padding: 20px 24px 24px; }
@media (max-width: 720px) { .weight-evidence__heading { flex-direction: column; } .weight-evidence__heading dl { text-align: left; } }
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: auto minmax(0, 1fr) auto; } .order-mark { display: none; } .weight-evidence { padding: 10px; } .weight-evidence-grid { grid-template-columns: 1fr; } }
</style>