优化
This commit is contained in:
@@ -4,7 +4,6 @@ import AppLayout from '@/app/layouts/AppLayout.vue'
|
|||||||
import { decideRouteAccess } from '@/core/routing/accessDecision'
|
import { decideRouteAccess } from '@/core/routing/accessDecision'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { downloadRoutes } from '@/modules/downloads/routes'
|
import { downloadRoutes } from '@/modules/downloads/routes'
|
||||||
import { firstLegOrderRoutes } from '@/modules/first-leg-orders/routes'
|
|
||||||
import { inboundRoutes } from '@/modules/inbounds/routes'
|
import { inboundRoutes } from '@/modules/inbounds/routes'
|
||||||
import { unifiedOrderRoutes } from '@/modules/unified-orders/routes'
|
import { unifiedOrderRoutes } from '@/modules/unified-orders/routes'
|
||||||
import { fbaPricingRoutes } from '@/modules/fba-pricing/routes'
|
import { fbaPricingRoutes } from '@/modules/fba-pricing/routes'
|
||||||
@@ -62,7 +61,6 @@ const router = createRouter({
|
|||||||
...customerRoutes,
|
...customerRoutes,
|
||||||
...warehouseRoutes,
|
...warehouseRoutes,
|
||||||
...unifiedOrderRoutes,
|
...unifiedOrderRoutes,
|
||||||
...firstLegOrderRoutes,
|
|
||||||
...inboundRoutes,
|
...inboundRoutes,
|
||||||
...fbaPricingRoutes,
|
...fbaPricingRoutes,
|
||||||
...warehouseResourceRoutes,
|
...warehouseResourceRoutes,
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
title: '通用',
|
title: '通用',
|
||||||
icon: PrototypeGeneralIcon,
|
icon: PrototypeGeneralIcon,
|
||||||
children: [
|
children: [
|
||||||
// { key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'platform:order:list', hidden: true },
|
|
||||||
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
|
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
|
||||||
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' },
|
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' },
|
||||||
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
|
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
acceptPlatformFirstLegOrder,
|
acceptPlatformFirstLegOrder,
|
||||||
addPlatformFirstLegOrderNote,
|
addPlatformFirstLegOrderNote,
|
||||||
approvePlatformFirstLegOrder,
|
approvePlatformFirstLegOrder,
|
||||||
batchApprovePlatformFirstLegOrders,
|
|
||||||
cancelPlatformFirstLegOrder,
|
cancelPlatformFirstLegOrder,
|
||||||
completePlatformFirstLegOrder,
|
completePlatformFirstLegOrder,
|
||||||
getPlatformFirstLegOrder,
|
getPlatformFirstLegOrder,
|
||||||
@@ -97,22 +96,6 @@ describe('platform first-leg order API', () => {
|
|||||||
expect(http.post).toHaveBeenNthCalledWith(6, '/platform/v1/orders/first-leg/90001/internal-notes', { content: '已联系客户确认' }, { params: { tenantId: '10001' } })
|
expect(http.post).toHaveBeenNthCalledWith(6, '/platform/v1/orders/first-leg/90001/internal-notes', { content: '已联系客户确认' }, { params: { tenantId: '10001' } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the formal tenant-scoped batch approval contract and preserves long order ids', async () => {
|
|
||||||
vi.mocked(http.post).mockResolvedValue({
|
|
||||||
code: '200', message: 'success',
|
|
||||||
data: { total: 2, successCount: 1, failureCount: 1, successOrderIds: ['2089223263733518337'], failedOrders: [{ orderId: '2089223263733518338', code: 'A0409', message: '订单状态已变化' }] },
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(batchApprovePlatformFirstLegOrders('2089176869176389634', [
|
|
||||||
'2089223263733518337', '2089223263733518338',
|
|
||||||
])).resolves.toMatchObject({ total: 2, successCount: 1, failureCount: 1 })
|
|
||||||
expect(http.post).toHaveBeenCalledWith(
|
|
||||||
'/platform/v1/orders/first-leg/batch-approve',
|
|
||||||
{ orderIds: ['2089223263733518337', '2089223263733518338'] },
|
|
||||||
{ params: { tenantId: '2089176869176389634' } },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('writes the backend express contract as names, not a guessed master-data ID', async () => {
|
it('writes the backend express contract as names, not a guessed master-data ID', async () => {
|
||||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
|
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||||
|
|
||||||
|
|||||||
@@ -115,20 +115,6 @@ export interface PlatformTenantOrderStatistics {
|
|||||||
asOf: string
|
asOf: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformOrderBatchReviewFailure {
|
|
||||||
orderId: number | string
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlatformOrderBatchReviewResult {
|
|
||||||
total: number
|
|
||||||
successCount: number
|
|
||||||
failureCount: number
|
|
||||||
successOrderIds: Array<number | string>
|
|
||||||
failedOrders: PlatformOrderBatchReviewFailure[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type PlatformFirstLegOrderPagePayload = Omit<PlatformFirstLegOrderPageResource, 'page' | 'size' | 'total' | 'pages'> & {
|
type PlatformFirstLegOrderPagePayload = Omit<PlatformFirstLegOrderPageResource, 'page' | 'size' | 'total' | 'pages'> & {
|
||||||
page: number | string
|
page: number | string
|
||||||
size: number | string
|
size: number | string
|
||||||
@@ -190,14 +176,6 @@ export function approvePlatformFirstLegOrder(tenantId: string, orderId: string)
|
|||||||
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/approve`, undefined, { tenantId })
|
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/approve`, undefined, { tenantId })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function batchApprovePlatformFirstLegOrders(tenantId: string, orderIds: string[]) {
|
|
||||||
return postData<PlatformOrderBatchReviewResult>(
|
|
||||||
'/platform/v1/orders/first-leg/batch-approve',
|
|
||||||
{ orderIds },
|
|
||||||
{ tenantId },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function acceptPlatformFirstLegOrder(tenantId: string, orderId: string) {
|
export function acceptPlatformFirstLegOrder(tenantId: string, orderId: string) {
|
||||||
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/accept`, undefined, { tenantId })
|
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/accept`, undefined, { tenantId })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
|
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue'), 'utf8')
|
|
||||||
|
|
||||||
describe('PlatformFirstLegOrdersPanel review queue', () => {
|
|
||||||
it('loads one cross-tenant page by default and keeps enterprise as an optional filter', () => {
|
|
||||||
expect(source).toContain("status: 'SUBMITTED'")
|
|
||||||
expect(source).toContain("tenantId: filters.value.tenantId || undefined")
|
|
||||||
expect(source).toContain('listPlatformTenants')
|
|
||||||
expect(source).toContain("key: 'tenantId'")
|
|
||||||
expect(source).toContain('customerDisplay(row as PlatformFirstLegOrderResource).name')
|
|
||||||
expect(source).toContain('result.pages')
|
|
||||||
|
|
||||||
const queryFieldsSource = source.slice(source.indexOf('const queryFields'), source.indexOf('const orderColumns'))
|
|
||||||
expect(source).toContain("listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })")
|
|
||||||
expect(queryFieldsSource).toContain("key: 'tenantId'")
|
|
||||||
expect(queryFieldsSource).toContain("type: 'select'")
|
|
||||||
expect(queryFieldsSource).toContain('filterable: true')
|
|
||||||
expect(queryFieldsSource).toContain('tenant.legalName?.trim() || tenant.tenantName')
|
|
||||||
expect(queryFieldsSource).not.toContain('tenantCode')
|
|
||||||
expect(queryFieldsSource).not.toContain('客户编码')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not retain the historical fixed tenant', () => {
|
|
||||||
expect(source).not.toContain('PlatformTenantSelector')
|
|
||||||
expect(source).not.toContain('selectedTenantId')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resets to the pending review queue instead of all statuses', () => {
|
|
||||||
expect(source).toContain(':search-on-reset="false"')
|
|
||||||
expect(source).toContain("filters.value = { ...filters.value, status: 'SUBMITTED' }")
|
|
||||||
expect(source).toContain('await nextTick()')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('supports tenant-aware batch approval with partial failure feedback', () => {
|
|
||||||
expect(source).toContain("key: 'selection', type: 'selection'")
|
|
||||||
expect(source).toContain("row.status === 'SUBMITTED' && !batchReviewLoading.value")
|
|
||||||
expect(source).toContain('@selection-change="handleReviewSelectionChange"')
|
|
||||||
expect(source).toContain('<template #toolbar-actions>')
|
|
||||||
expect(source).toContain('批量审核通过')
|
|
||||||
expect(source).toContain('groupOrdersByTenant(selected)')
|
|
||||||
expect(source.indexOf('batchReviewLoading.value = true')).toBeLessThan(source.indexOf('await ElMessageBox.confirm('))
|
|
||||||
expect(source).toContain('batchApprovePlatformFirstLegOrders(')
|
|
||||||
expect(source).toContain('successCount += result.successCount')
|
|
||||||
expect(source).toContain('failureCount += result.failureCount')
|
|
||||||
expect(source).toContain('成功 ${successCount} 个,失败 ${failureCount} 个')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the operation column first before the batch selection column', () => {
|
|
||||||
const actionColumn = source.indexOf("{ key: 'actions', label: '操作'")
|
|
||||||
const selectionColumn = source.indexOf("key: 'selection', type: 'selection'")
|
|
||||||
expect(actionColumn).toBeGreaterThan(0)
|
|
||||||
expect(selectionColumn).toBeGreaterThan(actionColumn)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows up to three row actions directly and overflows only the fourth action onward', () => {
|
|
||||||
expect(source).toContain('class="table-actions order-row-actions"')
|
|
||||||
expect(source).toContain('directSecondaryActionCount')
|
|
||||||
expect(source).toContain('Math.max(0, 3 - primaryActionCount)')
|
|
||||||
expect(source).toContain("isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'express')")
|
|
||||||
expect(source).toContain("isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'note')")
|
|
||||||
expect(source).toContain("isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'cancel')")
|
|
||||||
expect(source).toContain('v-if="hasOverflowActions(row as PlatformFirstLegOrderResource)"')
|
|
||||||
expect(source).toContain(':aria-label="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)"')
|
|
||||||
expect(source).toContain(':title="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)"')
|
|
||||||
expect(source).toContain('维护快递')
|
|
||||||
expect(source).toContain('内部备注')
|
|
||||||
expect(source).toContain('取消订单')
|
|
||||||
expect(source).toContain("minWidth: 288, fixed: 'left'")
|
|
||||||
expect(source).toContain('size="small" plain type="success"')
|
|
||||||
expect(source).toContain('size="small" plain type="primary"')
|
|
||||||
expect(source).toContain('size="small" plain type="danger"')
|
|
||||||
expect(source).toContain('<Eye :size="15" />详情')
|
|
||||||
expect(source).not.toContain('v-if="actionLabel(row as PlatformFirstLegOrderResource)" link')
|
|
||||||
expect(source).toContain('@media (max-width: 360px)')
|
|
||||||
expect(source).toContain('.order-row-actions :deep(.el-button) { width: 100%; min-height: 40px; }')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('distinguishes overflow action buttons by order and keeps a stable missing-field fallback', () => {
|
|
||||||
expect(source).toContain('function moreOrderActionsLabel(order: PlatformFirstLegOrderResource)')
|
|
||||||
expect(source).toContain('[compatibleOrder.orderNo, compatibleOrder.id, compatibleOrder.orderId]')
|
|
||||||
expect(source).toContain("identifier || '当前订单'")
|
|
||||||
expect(source).not.toContain('aria-label="更多订单操作"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses structured list regions so toolbar and table keep one layout gap', () => {
|
|
||||||
expect(source).toContain('<ListPageLayout class="first-leg-order-list"')
|
|
||||||
expect(source).toContain('<template #toolbar>')
|
|
||||||
expect(source).toContain('<template v-if="tenantLoadError || loadError" #state>')
|
|
||||||
expect(source).toContain('<template #default>')
|
|
||||||
expect(source).toContain('<section v-if="!loadError" class="surface-panel table-panel">')
|
|
||||||
expect(source).toContain('.first-leg-order-list { gap: 16px; }')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prioritizes order status and removes noisy customer and time metadata', () => {
|
|
||||||
const statusColumn = source.indexOf("{ key: 'status', label: '状态'")
|
|
||||||
const orderColumn = source.indexOf("{ key: 'orderInfo', label: '订单信息'")
|
|
||||||
expect(statusColumn).toBeGreaterThan(0)
|
|
||||||
expect(statusColumn).toBeLessThan(orderColumn)
|
|
||||||
expect(source).toContain("replace('T', ' ').slice(0, 16)")
|
|
||||||
expect(source).toContain('客户编码 ${code}')
|
|
||||||
expect(source).not.toContain('customerName }} · {{ (row as PlatformFirstLegOrderResource).tenantId')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('passes the order tenant through detail and lifecycle actions', () => {
|
|
||||||
expect(source).toContain('tenantId: String(order.tenantId)')
|
|
||||||
expect(source).toContain('getPlatformFirstLegOrder(tenantId, orderId)')
|
|
||||||
expect(source).toContain('approvePlatformFirstLegOrder(tenantId, String(order.id))')
|
|
||||||
expect(source).not.toContain('loadedTenantId.value')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not expose unknown backend status or history action codes', () => {
|
|
||||||
expect(source).toContain("?.label || '未知状态'")
|
|
||||||
expect(source).toContain('item.reason || historyActionLabel(item.action)')
|
|
||||||
expect(source).not.toContain('item.reason || item.action')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('formats cargo measurements without exposing floating-point tails', () => {
|
|
||||||
expect(source).toContain("numeric.toLocaleString('zh-CN', { maximumFractionDigits: 3 })")
|
|
||||||
expect(source).toContain('formatMeasurement(row.grossWeightKg)')
|
|
||||||
expect(source).toContain('formatMeasurement(row.volumeCbm)')
|
|
||||||
expect(source).not.toContain('{{ row.grossWeightKg }} kg / {{ row.volumeCbm }} m³')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders cargo business data with the native table grid contract', () => {
|
|
||||||
expect(source).toContain('<el-table :data="detail.cargoes" size="small" border stripe')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,744 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
|
||||||
import { ArrowLeft, CircleCheckBig, CircleX, Eye, FilePenLine, MoreHorizontal, PackageCheck } from '@lucide/vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import {
|
|
||||||
acceptPlatformFirstLegOrder,
|
|
||||||
addPlatformFirstLegOrderNote,
|
|
||||||
approvePlatformFirstLegOrder,
|
|
||||||
batchApprovePlatformFirstLegOrders,
|
|
||||||
cancelPlatformFirstLegOrder,
|
|
||||||
completePlatformFirstLegOrder,
|
|
||||||
getPlatformFirstLegOrder,
|
|
||||||
listPlatformFirstLegOrders,
|
|
||||||
startPlatformFirstLegOrder,
|
|
||||||
updatePlatformFirstLegOrderExpress,
|
|
||||||
type PlatformFirstLegOrderDetailResource,
|
|
||||||
type PlatformFirstLegOrderResource,
|
|
||||||
type PlatformFirstLegOrderStatus,
|
|
||||||
} from '@/modules/first-leg-orders/api/platformFirstLegOrders'
|
|
||||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
||||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
||||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|
||||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
||||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const access = useAccessPreviewStore()
|
|
||||||
const orders = ref<PlatformFirstLegOrderResource[]>([])
|
|
||||||
const tenants = ref<PlatformTenantResource[]>([])
|
|
||||||
const tenantLoadError = ref<ApiErrorDetails | null>(null)
|
|
||||||
const total = ref(0)
|
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = ref(20)
|
|
||||||
const loading = ref(false)
|
|
||||||
const actionLoadingId = ref('')
|
|
||||||
const batchReviewLoading = ref(false)
|
|
||||||
const selectedReviewOrders = ref<PlatformFirstLegOrderResource[]>([])
|
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
|
||||||
const detailLoading = ref(false)
|
|
||||||
const detail = ref<PlatformFirstLegOrderDetailResource | null>(null)
|
|
||||||
const noteDialogOpen = ref(false)
|
|
||||||
const expressDialogOpen = ref(false)
|
|
||||||
const selectedOrder = ref<PlatformFirstLegOrderResource | null>(null)
|
|
||||||
const noteForm = reactive({ content: '' })
|
|
||||||
const noteSaving = ref(false)
|
|
||||||
const expressSaving = ref(false)
|
|
||||||
const expressFormRef = ref<FormInstance>()
|
|
||||||
const noteFormRef = ref<FormInstance>()
|
|
||||||
const expressForm = reactive({ expressCompany: '', expressTrackingNo: '' })
|
|
||||||
const filters = ref<QueryModel & {
|
|
||||||
tenantId: string
|
|
||||||
keyword: string
|
|
||||||
status: PlatformFirstLegOrderStatus | ''
|
|
||||||
dateRange: string[]
|
|
||||||
}>({
|
|
||||||
tenantId: '',
|
|
||||||
keyword: '',
|
|
||||||
status: 'SUBMITTED',
|
|
||||||
dateRange: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const queryFields = computed<QueryField[]>(() => [
|
|
||||||
{
|
|
||||||
key: 'tenantId',
|
|
||||||
type: 'select',
|
|
||||||
label: '企业客户',
|
|
||||||
width: 160,
|
|
||||||
placeholder: tenantLoadError.value ? '企业列表加载失败' : '全部企业',
|
|
||||||
props: { 'aria-label': '企业客户', filterable: true, disabled: Boolean(tenantLoadError.value) },
|
|
||||||
options: tenants.value.map((tenant) => ({ value: String(tenant.id), label: tenant.legalName?.trim() || tenant.tenantName })),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
type: 'select',
|
|
||||||
label: '订单状态',
|
|
||||||
width: 120,
|
|
||||||
placeholder: '全部状态',
|
|
||||||
props: { 'aria-label': '订单状态' },
|
|
||||||
options: [
|
|
||||||
{ value: 'DRAFT', label: '草稿' },
|
|
||||||
{ value: 'SUBMITTED', label: '待审核' },
|
|
||||||
{ value: 'APPROVED', label: '已审核' },
|
|
||||||
{ value: 'ACCEPTED', label: '已受理' },
|
|
||||||
{ value: 'PROCESSING', label: '处理中' },
|
|
||||||
{ value: 'COMPLETED', label: '已完成' },
|
|
||||||
{ value: 'CANCELLED', label: '已取消' },
|
|
||||||
{ value: 'REJECTED', label: '已驳回(旧契约)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ key: 'keyword', type: 'input', label: '订单关键词', placeholder: '订单号或发货客户名称', props: { 'aria-label': '订单关键词' } },
|
|
||||||
{ key: 'dateRange', type: 'daterange', label: '订单创建日期', props: { 'aria-label': '订单创建日期' } },
|
|
||||||
])
|
|
||||||
|
|
||||||
const orderColumns = computed<DataTableColumn[]>(() => [
|
|
||||||
{ key: 'actions', label: '操作', minWidth: 288, fixed: 'left', slotName: 'actions' },
|
|
||||||
...(can('platform:order:approve') ? [{
|
|
||||||
key: 'selection', type: 'selection', width: 48, fixed: 'left',
|
|
||||||
selectable: (row: PlatformFirstLegOrderResource) => row.status === 'SUBMITTED' && !batchReviewLoading.value,
|
|
||||||
} satisfies DataTableColumn] : []),
|
|
||||||
{ key: 'status', label: '状态', width: 108, slotName: 'status' },
|
|
||||||
{ key: 'orderInfo', label: '订单信息', minWidth: 210, slotName: 'orderInfo' },
|
|
||||||
{ key: 'customer', label: '客户', minWidth: 190, showOverflowTooltip: true, slotName: 'customer' },
|
|
||||||
{ key: 'transport', label: '运输方案', minWidth: 180, slotName: 'transport' },
|
|
||||||
{ key: 'destination', label: '目的地', minWidth: 150, slotName: 'destination' },
|
|
||||||
{ key: 'receivingWarehouse', label: '平台收货仓', minWidth: 180, slotName: 'receivingWarehouse' },
|
|
||||||
{ key: 'express', label: '履约快递', minWidth: 160, slotName: 'express' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const expressRules: FormRules = {
|
|
||||||
expressCompany: [{ required: true, message: '请填写快递公司', trigger: 'blur' }],
|
|
||||||
expressTrackingNo: [
|
|
||||||
{ required: true, message: '请填写快递单号', trigger: 'blur' },
|
|
||||||
{ pattern: /^[A-Za-z0-9-]{4,128}$/, message: '快递单号格式不正确', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const noteRules: FormRules = {
|
|
||||||
content: [{ required: true, message: '请填写内部备注', trigger: 'blur' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
const detailOrderId = computed(() => {
|
|
||||||
const orderId = route.query.orderId
|
|
||||||
return typeof orderId === 'string' && orderId ? orderId : ''
|
|
||||||
})
|
|
||||||
const detailTenantId = computed(() => {
|
|
||||||
const tenantId = route.query.tenantId
|
|
||||||
return typeof tenantId === 'string' && tenantId ? tenantId : ''
|
|
||||||
})
|
|
||||||
const isDetailPage = computed(() => Boolean(detailOrderId.value))
|
|
||||||
|
|
||||||
const statusOptions: Array<{ value: PlatformFirstLegOrderStatus; label: string }> = [
|
|
||||||
{ value: 'DRAFT', label: '草稿' },
|
|
||||||
{ value: 'SUBMITTED', label: '待审核' },
|
|
||||||
{ value: 'APPROVED', label: '已审核' },
|
|
||||||
{ value: 'ACCEPTED', label: '已受理' },
|
|
||||||
{ value: 'PROCESSING', label: '处理中' },
|
|
||||||
{ value: 'COMPLETED', label: '已完成' },
|
|
||||||
{ value: 'CANCELLED', label: '已取消' },
|
|
||||||
{ value: 'REJECTED', label: '已驳回(旧契约)' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function statusLabel(status: PlatformFirstLegOrderStatus) {
|
|
||||||
return statusOptions.find((item) => item.value === status)?.label || '未知状态'
|
|
||||||
}
|
|
||||||
|
|
||||||
function historyActionLabel(action: string | null | undefined) {
|
|
||||||
return ({
|
|
||||||
CREATE: '创建订单', CREATED: '创建订单', SUBMIT: '提交订单', SUBMITTED: '提交订单',
|
|
||||||
APPROVE: '审核通过', APPROVED: '审核通过', REJECT: '审核驳回', REJECTED: '审核驳回',
|
|
||||||
CANCEL: '取消订单', CANCELLED: '取消订单', STATUS_UPDATE: '状态更新',
|
|
||||||
} as Record<string, string>)[action || ''] || '状态已更新。'
|
|
||||||
}
|
|
||||||
|
|
||||||
function transportLabel(order: PlatformFirstLegOrderResource) {
|
|
||||||
if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? '海运整箱 FCL' : '海运拼箱 LCL'
|
|
||||||
if (order.transportMode === 'AIR') return '空运'
|
|
||||||
if (order.transportMode === 'RAIL') return '铁路运输'
|
|
||||||
return '陆运'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formattedDate(value: string | null | undefined) {
|
|
||||||
return value ? value.replace('T', ' ').slice(0, 16) : '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMeasurement(value: unknown) {
|
|
||||||
const numeric = Number(value)
|
|
||||||
return value === null || value === undefined || value === '' || !Number.isFinite(numeric)
|
|
||||||
? '—'
|
|
||||||
: numeric.toLocaleString('zh-CN', { maximumFractionDigits: 3 })
|
|
||||||
}
|
|
||||||
|
|
||||||
function tenantFor(tenantId: number | string) {
|
|
||||||
return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))
|
|
||||||
}
|
|
||||||
|
|
||||||
function tenantName(tenantId: number | string) {
|
|
||||||
return tenantFor(tenantId)?.tenantName || `企业 ${tenantId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function customerDisplay(order: PlatformFirstLegOrderResource) {
|
|
||||||
const tenant = tenantFor(order.tenantId)
|
|
||||||
const name = tenant?.legalName?.trim() || tenant?.tenantName?.trim() || order.customerName?.trim() || '未命名企业'
|
|
||||||
const code = tenant?.tenantCode?.trim()
|
|
||||||
const secondary = code ? `客户编码 ${code}` : order.customerName?.trim()
|
|
||||||
return { name, secondary: secondary && secondary !== name ? secondary : '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTenants() {
|
|
||||||
tenantLoadError.value = null
|
|
||||||
try {
|
|
||||||
const records: PlatformTenantResource[] = []
|
|
||||||
let currentPage = 1
|
|
||||||
let pages = 1
|
|
||||||
do {
|
|
||||||
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
|
|
||||||
records.push(...result.records)
|
|
||||||
pages = Math.max(1, result.pages)
|
|
||||||
currentPage += 1
|
|
||||||
} while (currentPage <= pages)
|
|
||||||
tenants.value = records
|
|
||||||
} catch (error) {
|
|
||||||
tenants.value = []
|
|
||||||
tenantLoadError.value = getApiErrorDetails(error, '企业列表加载失败,暂时无法按企业筛选;订单仍可正常审核')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function can(action: string) {
|
|
||||||
return access.can(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
function canCancel(order: PlatformFirstLegOrderResource) {
|
|
||||||
return can('platform:order:cancel') && ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED'].includes(order.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
function canMaintainExpress(order: PlatformFirstLegOrderResource) {
|
|
||||||
return can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes(order.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SecondaryAction = 'express' | 'note' | 'cancel'
|
|
||||||
|
|
||||||
function secondaryActions(order: PlatformFirstLegOrderResource): SecondaryAction[] {
|
|
||||||
const actions: SecondaryAction[] = []
|
|
||||||
if (canMaintainExpress(order)) actions.push('express')
|
|
||||||
if (can('platform:order:note')) actions.push('note')
|
|
||||||
if (canCancel(order)) actions.push('cancel')
|
|
||||||
return actions
|
|
||||||
}
|
|
||||||
|
|
||||||
function directSecondaryActionCount(order: PlatformFirstLegOrderResource) {
|
|
||||||
const primaryActionCount = actionLabel(order) ? 2 : 1
|
|
||||||
return Math.max(0, 3 - primaryActionCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDirectSecondaryAction(order: PlatformFirstLegOrderResource, action: SecondaryAction) {
|
|
||||||
const index = secondaryActions(order).indexOf(action)
|
|
||||||
return index >= 0 && index < directSecondaryActionCount(order)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOverflowSecondaryAction(order: PlatformFirstLegOrderResource, action: SecondaryAction) {
|
|
||||||
const index = secondaryActions(order).indexOf(action)
|
|
||||||
return index >= directSecondaryActionCount(order)
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasOverflowActions(order: PlatformFirstLegOrderResource) {
|
|
||||||
return secondaryActions(order).length > directSecondaryActionCount(order)
|
|
||||||
}
|
|
||||||
|
|
||||||
function moreOrderActionsLabel(order: PlatformFirstLegOrderResource) {
|
|
||||||
const compatibleOrder = order as PlatformFirstLegOrderResource & { orderId?: string | number | null }
|
|
||||||
const identifier = [compatibleOrder.orderNo, compatibleOrder.id, compatibleOrder.orderId]
|
|
||||||
.map((value) => String(value ?? '').trim())
|
|
||||||
.find(Boolean)
|
|
||||||
return `更多订单操作:${identifier || '当前订单'}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function runSecondaryAction(command: string | number | object, order: PlatformFirstLegOrderResource) {
|
|
||||||
if (command === 'express') openExpress(order)
|
|
||||||
if (command === 'note') openNote(order)
|
|
||||||
if (command === 'cancel') void cancelOrder(order)
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionLabel(order: PlatformFirstLegOrderResource) {
|
|
||||||
if (order.status === 'SUBMITTED' && can('platform:order:approve')) return '审核通过'
|
|
||||||
if (order.status === 'APPROVED' && can('platform:order:accept')) return '受理订单'
|
|
||||||
if (order.status === 'ACCEPTED' && can('platform:order:process')) return '开始处理'
|
|
||||||
if (order.status === 'PROCESSING' && can('platform:order:complete')) return '完成订单'
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadOrders() {
|
|
||||||
loadError.value = null
|
|
||||||
selectedReviewOrders.value = []
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const result = await listPlatformFirstLegOrders({
|
|
||||||
tenantId: filters.value.tenantId || undefined,
|
|
||||||
keyword: filters.value.keyword.trim() || undefined,
|
|
||||||
status: filters.value.status || undefined,
|
|
||||||
createdFrom: filters.value.dateRange.length === 2 ? `${filters.value.dateRange[0]}T00:00:00` : undefined,
|
|
||||||
createdTo: filters.value.dateRange.length === 2 ? `${filters.value.dateRange[1]}T23:59:59` : undefined,
|
|
||||||
page: page.value,
|
|
||||||
size: pageSize.value,
|
|
||||||
})
|
|
||||||
orders.value = result.records
|
|
||||||
total.value = result.total
|
|
||||||
} catch (error) {
|
|
||||||
orders.value = []
|
|
||||||
total.value = 0
|
|
||||||
loadError.value = getApiErrorDetails(error, '订单列表加载失败,请检查 Gateway、租户范围和订单权限')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReviewSelectionChange(rows: PlatformFirstLegOrderResource[]) {
|
|
||||||
selectedReviewOrders.value = rows.filter((row) => row.status === 'SUBMITTED')
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupOrdersByTenant(selected: PlatformFirstLegOrderResource[]) {
|
|
||||||
const groups = new Map<string, PlatformFirstLegOrderResource[]>()
|
|
||||||
for (const order of selected) {
|
|
||||||
const tenantId = String(order.tenantId)
|
|
||||||
groups.set(tenantId, [...(groups.get(tenantId) || []), order])
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
async function batchApproveSelectedOrders() {
|
|
||||||
const selected = [...selectedReviewOrders.value]
|
|
||||||
if (!selected.length || batchReviewLoading.value) return
|
|
||||||
const customerCount = new Set(selected.map((order) => String(order.tenantId))).size
|
|
||||||
batchReviewLoading.value = true
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
`确认批量审核通过已勾选的 ${selected.length} 个订单吗?涉及 ${customerCount} 个客户,审核成功后订单将进入待测量。`,
|
|
||||||
'批量审核通过',
|
|
||||||
{ confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
|
|
||||||
)
|
|
||||||
let successCount = 0
|
|
||||||
let failureCount = 0
|
|
||||||
const failureMessages: string[] = []
|
|
||||||
for (const [tenantId, tenantOrders] of groupOrdersByTenant(selected)) {
|
|
||||||
try {
|
|
||||||
const result = await batchApprovePlatformFirstLegOrders(
|
|
||||||
tenantId,
|
|
||||||
tenantOrders.map((order) => String(order.id)),
|
|
||||||
)
|
|
||||||
successCount += result.successCount
|
|
||||||
failureCount += result.failureCount
|
|
||||||
failureMessages.push(...result.failedOrders.map((failure) => failure.message))
|
|
||||||
} catch (error) {
|
|
||||||
failureCount += tenantOrders.length
|
|
||||||
failureMessages.push(getApiErrorDetails(error, '该客户订单批量审核失败').message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await loadOrders()
|
|
||||||
const reasons = [...new Set(failureMessages.filter(Boolean))].slice(0, 3).join(';')
|
|
||||||
if (failureCount === 0) {
|
|
||||||
ElMessage.success(`已批量审核通过 ${successCount} 个订单`)
|
|
||||||
} else if (successCount > 0) {
|
|
||||||
ElMessage.warning(`批量审核完成:成功 ${successCount} 个,失败 ${failureCount} 个${reasons ? `。${reasons}` : ''}`)
|
|
||||||
} else {
|
|
||||||
ElMessage.error(`批量审核失败:${failureCount} 个订单未通过${reasons ? `。${reasons}` : ''}`)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error === 'cancel' || error === 'close') return
|
|
||||||
ElMessage.error(getApiErrorDetails(error, '批量审核失败,请稍后重试').message)
|
|
||||||
} finally {
|
|
||||||
batchReviewLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchOrders() {
|
|
||||||
if (page.value !== 1) {
|
|
||||||
page.value = 1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void loadOrders()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetOrders() {
|
|
||||||
filters.value = { ...filters.value, status: 'SUBMITTED' }
|
|
||||||
await nextTick()
|
|
||||||
searchOrders()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([page, pageSize], () => {
|
|
||||||
void loadOrders()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function openDetail(order: PlatformFirstLegOrderResource) {
|
|
||||||
selectedOrder.value = order
|
|
||||||
await router.push({
|
|
||||||
path: route.path,
|
|
||||||
query: { ...route.query, orderId: String(order.id), tenantId: String(order.tenantId) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDetail(orderId: string, tenantId = detailTenantId.value) {
|
|
||||||
if (!tenantId) {
|
|
||||||
detail.value = null
|
|
||||||
ElMessage.error('订单详情缺少企业租户标识,请从订单列表重新打开')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
detail.value = null
|
|
||||||
detailLoading.value = true
|
|
||||||
try {
|
|
||||||
detail.value = await getPlatformFirstLegOrder(tenantId, orderId)
|
|
||||||
selectedOrder.value = detail.value.order
|
|
||||||
} catch (error) {
|
|
||||||
const details = getApiErrorDetails(error, '订单详情加载失败')
|
|
||||||
ElMessage.error(details.message)
|
|
||||||
} finally {
|
|
||||||
detailLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAfterAction(orderId: string) {
|
|
||||||
await loadOrders()
|
|
||||||
if (detailOrderId.value === orderId) {
|
|
||||||
await loadDetail(orderId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDetailPage() {
|
|
||||||
const { orderId: _orderId, tenantId: _tenantId, ...query } = route.query
|
|
||||||
void router.replace({ path: route.path, query })
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFieldValidationMessage(error: unknown, field: string, fallback: string) {
|
|
||||||
const details = getApiErrorDetails(error, fallback)
|
|
||||||
if (error instanceof ApiResponseError && error.details && typeof error.details === 'object') {
|
|
||||||
const fieldMessage = (error.details as Record<string, unknown>)[field]
|
|
||||||
if (typeof fieldMessage === 'string' && fieldMessage) {
|
|
||||||
return `${details.message}:${fieldMessage}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return details.message
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runLifecycleAction(order: PlatformFirstLegOrderResource) {
|
|
||||||
const label = actionLabel(order)
|
|
||||||
if (!label) return
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确认${label}“${order.orderNo || order.id}”吗?该操作会写入订单状态历史。`, label, {
|
|
||||||
confirmButtonText: `确认${label}`,
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
actionLoadingId.value = String(order.id)
|
|
||||||
const tenantId = String(order.tenantId)
|
|
||||||
if (order.status === 'SUBMITTED') await approvePlatformFirstLegOrder(tenantId, String(order.id))
|
|
||||||
if (order.status === 'APPROVED') await acceptPlatformFirstLegOrder(tenantId, String(order.id))
|
|
||||||
if (order.status === 'ACCEPTED') await startPlatformFirstLegOrder(tenantId, String(order.id))
|
|
||||||
if (order.status === 'PROCESSING') await completePlatformFirstLegOrder(tenantId, String(order.id))
|
|
||||||
await refreshAfterAction(String(order.id))
|
|
||||||
ElMessage.success(`${label}已完成`)
|
|
||||||
} catch (error) {
|
|
||||||
if (error === 'cancel' || error === 'close') return
|
|
||||||
ElMessage.error(getApiErrorDetails(error, `${label}失败,请稍后重试`).message)
|
|
||||||
} finally {
|
|
||||||
actionLoadingId.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancelOrder(order: PlatformFirstLegOrderResource) {
|
|
||||||
try {
|
|
||||||
const result = await ElMessageBox.prompt('请填写取消原因。后端要求该字段必填,并将写入订单状态历史。', '取消订单', {
|
|
||||||
confirmButtonText: '确认取消',
|
|
||||||
cancelButtonText: '返回',
|
|
||||||
inputPattern: /\S+/,
|
|
||||||
inputErrorMessage: '请填写取消原因',
|
|
||||||
inputPlaceholder: '例如:客户取消本次运输安排',
|
|
||||||
})
|
|
||||||
actionLoadingId.value = String(order.id)
|
|
||||||
await cancelPlatformFirstLegOrder(String(order.tenantId), String(order.id), result.value.trim())
|
|
||||||
await refreshAfterAction(String(order.id))
|
|
||||||
ElMessage.success('订单已取消')
|
|
||||||
} catch (error) {
|
|
||||||
if (error === 'cancel' || error === 'close') return
|
|
||||||
ElMessage.error(getApiErrorDetails(error, '取消订单失败,请稍后重试').message)
|
|
||||||
} finally {
|
|
||||||
actionLoadingId.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openExpress(order: PlatformFirstLegOrderResource) {
|
|
||||||
selectedOrder.value = order
|
|
||||||
expressForm.expressCompany = order.expressCompany || ''
|
|
||||||
expressForm.expressTrackingNo = order.expressTrackingNo || ''
|
|
||||||
expressDialogOpen.value = true
|
|
||||||
void nextTick(() => expressFormRef.value?.clearValidate())
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveExpress() {
|
|
||||||
if (!selectedOrder.value) return
|
|
||||||
const valid = await expressFormRef.value?.validate().catch(() => false)
|
|
||||||
if (!valid) return
|
|
||||||
expressSaving.value = true
|
|
||||||
try {
|
|
||||||
await updatePlatformFirstLegOrderExpress(String(selectedOrder.value.tenantId), String(selectedOrder.value.id), {
|
|
||||||
expressCompany: expressForm.expressCompany.trim(),
|
|
||||||
expressTrackingNo: expressForm.expressTrackingNo.trim(),
|
|
||||||
})
|
|
||||||
expressDialogOpen.value = false
|
|
||||||
await refreshAfterAction(String(selectedOrder.value.id))
|
|
||||||
ElMessage.success('履约快递信息已保存')
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(getFieldValidationMessage(error, 'expressTrackingNo', '履约快递信息保存失败'))
|
|
||||||
} finally {
|
|
||||||
expressSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openNote(order: PlatformFirstLegOrderResource) {
|
|
||||||
selectedOrder.value = order
|
|
||||||
noteForm.content = ''
|
|
||||||
noteDialogOpen.value = true
|
|
||||||
void nextTick(() => noteFormRef.value?.clearValidate())
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveNote() {
|
|
||||||
if (!selectedOrder.value) return
|
|
||||||
const valid = await noteFormRef.value?.validate().catch(() => false)
|
|
||||||
if (!valid) return
|
|
||||||
noteSaving.value = true
|
|
||||||
try {
|
|
||||||
await addPlatformFirstLegOrderNote(String(selectedOrder.value.tenantId), String(selectedOrder.value.id), noteForm.content.trim())
|
|
||||||
noteDialogOpen.value = false
|
|
||||||
await refreshAfterAction(String(selectedOrder.value.id))
|
|
||||||
ElMessage.success('内部备注已保存')
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(getApiErrorDetails(error, '内部备注保存失败').message)
|
|
||||||
} finally {
|
|
||||||
noteSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([detailOrderId, detailTenantId], ([orderId, tenantId]) => {
|
|
||||||
if (orderId) {
|
|
||||||
void loadDetail(orderId, tenantId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
detail.value = null
|
|
||||||
selectedOrder.value = null
|
|
||||||
void loadOrders()
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
onMounted(() => void loadTenants())
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AdminPage>
|
|
||||||
<div class="business-page">
|
|
||||||
<template v-if="isDetailPage">
|
|
||||||
<section class="surface-panel detail-page" v-loading="detailLoading">
|
|
||||||
<DetailPageLayout ariaLabel="头程订单详情">
|
|
||||||
<template #header>
|
|
||||||
<header class="detail-page-header">
|
|
||||||
<div>
|
|
||||||
<h1>订单详情</h1>
|
|
||||||
<p>查看订单信息、履约快递、内部备注和状态历史。</p>
|
|
||||||
</div>
|
|
||||||
<el-button @click="closeDetailPage"><ArrowLeft :size="16" />返回订单列表</el-button>
|
|
||||||
</header>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-if="detail">
|
|
||||||
<div class="order-detail-head">
|
|
||||||
<div>
|
|
||||||
<span>{{ transportLabel(detail.order) }}</span>
|
|
||||||
<h2>{{ detail.order.orderNo || detail.order.id }}</h2>
|
|
||||||
<p>{{ tenantName(detail.order.tenantId) }} · {{ detail.order.customerName }} · {{ detail.order.referenceId || '无客户参考号' }}</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge :status="statusLabel(detail.order.status)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-section">
|
|
||||||
<h3>订单概要</h3>
|
|
||||||
<el-descriptions :column="2" border>
|
|
||||||
<el-descriptions-item label="目标仓 / 国家">{{ detail.order.destinationWarehouseName || '-' }} · {{ detail.order.destinationCountryCode || '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="平台收货仓">{{ detail.order.receivingWarehouseName || '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="收货地址" :span="2">{{ detail.order.receivingWarehouseAddress || '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="渠道">{{ detail.order.channelName || '-' }}({{ detail.order.channelCode || '-' }})</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="预计出运">{{ detail.order.expectedShipDate || '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="客户备注" :span="2">{{ detail.order.customerRemark || '-' }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-section">
|
|
||||||
<h3>货物明细</h3>
|
|
||||||
<div class="detail-table-scroll">
|
|
||||||
<el-table :data="detail.cargoes" size="small" border stripe empty-text="暂无货物明细">
|
|
||||||
<el-table-column prop="itemNo" label="#" width="52" />
|
|
||||||
<el-table-column prop="cargoName" label="品名" min-width="150" />
|
|
||||||
<el-table-column prop="packageType" label="包装" min-width="100" />
|
|
||||||
<el-table-column prop="packageCount" label="件/箱" width="86" />
|
|
||||||
<el-table-column label="毛重 / 体积" min-width="150"><template #default="{ row }">{{ formatMeasurement(row.grossWeightKg) }} kg / {{ formatMeasurement(row.volumeCbm) }} m³</template></el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-section">
|
|
||||||
<h3>收发货方</h3>
|
|
||||||
<div class="party-grid">
|
|
||||||
<div v-for="party in detail.parties" :key="party.id">
|
|
||||||
<span>{{ party.partyType === 'SENDER' ? '发货方' : '收货方' }}</span>
|
|
||||||
<strong>{{ party.companyName }}</strong>
|
|
||||||
<p>{{ party.contactName }} · {{ party.phone }}</p>
|
|
||||||
<p>{{ [party.province, party.city, party.district, party.addressLine].filter(Boolean).join(' ') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-section">
|
|
||||||
<h3>履约快递</h3>
|
|
||||||
<el-descriptions :column="2" border>
|
|
||||||
<el-descriptions-item label="快递公司">{{ detail.order.expressCompany || '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="快递单号">{{ detail.order.expressTrackingNo || '-' }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-section">
|
|
||||||
<h3>内部备注</h3>
|
|
||||||
<el-empty v-if="!detail.internalNotes.length" description="暂无内部备注" :image-size="52" />
|
|
||||||
<el-timeline v-else><el-timeline-item v-for="note in detail.internalNotes" :key="note.id" :timestamp="formattedDate(note.createdAt)" placement="top"><p>{{ note.content }}</p></el-timeline-item></el-timeline>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-section">
|
|
||||||
<h3>状态历史</h3>
|
|
||||||
<el-timeline><el-timeline-item v-for="item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top"><strong>{{ statusLabel(item.toStatus) }}</strong><p>{{ item.reason || historyActionLabel(item.action) }}</p></el-timeline-item></el-timeline>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-empty v-else-if="!detailLoading" description="订单详情暂不可用"><el-button type="primary" @click="closeDetailPage"><ArrowLeft :size="16" />返回订单列表</el-button></el-empty>
|
|
||||||
</DetailPageLayout>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<ListPageLayout class="first-leg-order-list" ariaLabel="头程订单列表">
|
|
||||||
<template #toolbar>
|
|
||||||
<QueryToolbar
|
|
||||||
v-model="filters"
|
|
||||||
ariaLabel="头程订单筛选"
|
|
||||||
:fields="queryFields"
|
|
||||||
preference-key="first-leg-orders-query"
|
|
||||||
search-text="查询"
|
|
||||||
:search-on-reset="false"
|
|
||||||
@search="searchOrders"
|
|
||||||
@reset="resetOrders"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-if="tenantLoadError || loadError" #state>
|
|
||||||
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon role="alert">
|
|
||||||
<template #title><div class="api-error-alert-title"><span>{{ tenantLoadError.message }}</span><ApiErrorMeta :details="tenantLoadError" /></div></template>
|
|
||||||
<el-button link type="warning" @click="loadTenants">重新加载企业列表</el-button>
|
|
||||||
</el-alert>
|
|
||||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
|
|
||||||
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
|
|
||||||
<el-button link type="danger" @click="loadOrders">重新加载</el-button>
|
|
||||||
</el-alert>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #default>
|
|
||||||
<section v-if="!loadError" class="surface-panel table-panel">
|
|
||||||
<DataTable
|
|
||||||
v-model:page="page"
|
|
||||||
v-model:page-size="pageSize"
|
|
||||||
:data="orders"
|
|
||||||
:columns="orderColumns"
|
|
||||||
preference-key="first-leg-orders-table"
|
|
||||||
:loading="loading"
|
|
||||||
:total="total"
|
|
||||||
:page-sizes="[20, 50, 100]"
|
|
||||||
empty-text="当前没有符合条件的头程订单"
|
|
||||||
row-key="id"
|
|
||||||
@selection-change="handleReviewSelectionChange"
|
|
||||||
>
|
|
||||||
<template #toolbar-actions>
|
|
||||||
<el-button
|
|
||||||
v-if="can('platform:order:approve')"
|
|
||||||
type="success"
|
|
||||||
:loading="batchReviewLoading"
|
|
||||||
:disabled="loading || batchReviewLoading || selectedReviewOrders.length === 0"
|
|
||||||
@click="batchApproveSelectedOrders"
|
|
||||||
><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewOrders.length">({{ selectedReviewOrders.length }})</span></el-button>
|
|
||||||
</template>
|
|
||||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条订单</span></template>
|
|
||||||
<template #actions="{ row }">
|
|
||||||
<div class="table-actions order-row-actions">
|
|
||||||
<el-button link type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button>
|
|
||||||
<el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" size="small" plain type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)"><CircleCheckBig :size="15" />{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button>
|
|
||||||
<el-button v-if="isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'express')" size="small" plain type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openExpress(row as PlatformFirstLegOrderResource)"><PackageCheck :size="15" />维护快递</el-button>
|
|
||||||
<el-button v-if="isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'note')" size="small" plain type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openNote(row as PlatformFirstLegOrderResource)"><FilePenLine :size="15" />内部备注</el-button>
|
|
||||||
<el-button v-if="isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'cancel')" size="small" plain type="danger" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="cancelOrder(row as PlatformFirstLegOrderResource)"><CircleX :size="15" />取消订单</el-button>
|
|
||||||
<el-dropdown v-if="hasOverflowActions(row as PlatformFirstLegOrderResource)" trigger="click" popper-class="first-leg-action-menu" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @command="runSecondaryAction($event, row as PlatformFirstLegOrderResource)">
|
|
||||||
<el-button link type="primary" :aria-label="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)" :title="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)"><MoreHorizontal :size="17" /></el-button>
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu>
|
|
||||||
<el-dropdown-item v-if="isOverflowSecondaryAction(row as PlatformFirstLegOrderResource, 'express')" command="express"><PackageCheck :size="15" />维护快递</el-dropdown-item>
|
|
||||||
<el-dropdown-item v-if="isOverflowSecondaryAction(row as PlatformFirstLegOrderResource, 'note')" command="note"><FilePenLine :size="15" />内部备注</el-dropdown-item>
|
|
||||||
<el-dropdown-item v-if="isOverflowSecondaryAction(row as PlatformFirstLegOrderResource, 'cancel')" command="cancel" divided><CircleX :size="15" />取消订单</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #status="{ row }"><StatusBadge :status="statusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
|
|
||||||
<template #orderInfo="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).referenceId || '无客户参考号' }} · {{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></div></template>
|
|
||||||
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerDisplay(row as PlatformFirstLegOrderResource).name }}</strong><span v-if="customerDisplay(row as PlatformFirstLegOrderResource).secondary">{{ customerDisplay(row as PlatformFirstLegOrderResource).secondary }}</span></div></template>
|
|
||||||
<template #transport="{ row }"><div class="primary-cell"><strong>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).channelName || '未填写渠道' }}</span></div></template>
|
|
||||||
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template>
|
|
||||||
<template #receivingWarehouse="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template>
|
|
||||||
<template #express="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template>
|
|
||||||
</DataTable>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
</ListPageLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<el-dialog v-model="expressDialogOpen" title="维护履约快递" width="min(520px, calc(100vw - 28px))">
|
|
||||||
<el-form ref="expressFormRef" :model="expressForm" :rules="expressRules" label-position="top" class="delivery-form"><el-form-item label="快递公司" prop="expressCompany" required><el-input v-model.trim="expressForm.expressCompany" maxlength="64" placeholder="例如 顺丰速运" /></el-form-item><el-form-item label="快递单号" prop="expressTrackingNo" required><el-input v-model.trim="expressForm.expressTrackingNo" maxlength="128" placeholder="请输入完整快递单号" /></el-form-item></el-form>
|
|
||||||
<template #footer><el-button :disabled="expressSaving" @click="expressDialogOpen = false">取消</el-button><el-button type="primary" :loading="expressSaving" @click="saveExpress">保存快递信息</el-button></template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="noteDialogOpen" title="添加内部备注" width="min(520px, calc(100vw - 28px))">
|
|
||||||
<el-form ref="noteFormRef" :model="noteForm" :rules="noteRules" label-position="top"><el-form-item label="内部备注" prop="content" required><el-input v-model.trim="noteForm.content" maxlength="2000" type="textarea" :rows="4" show-word-limit placeholder="仅平台内部可见" /></el-form-item></el-form>
|
|
||||||
<template #footer><el-button :disabled="noteSaving" @click="noteDialogOpen = false">取消</el-button><el-button type="primary" :loading="noteSaving" @click="saveNote">保存备注</el-button></template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</AdminPage>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.detail-page { overflow: hidden; }
|
|
||||||
.first-leg-order-list { gap: 16px; }
|
|
||||||
.detail-page-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
|
|
||||||
.detail-page-header h1 { margin: 0; font-size: 20px; line-height: 1.35; }
|
|
||||||
.detail-page-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
|
||||||
.detail-table-scroll { overflow-x: auto; }
|
|
||||||
.party-grid p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
|
||||||
.order-row-actions { gap: 8px; white-space: nowrap; }
|
|
||||||
.order-row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
|
||||||
:global(.first-leg-action-menu .el-dropdown-menu__item) { min-height: 44px; gap: 8px; }
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.detail-page-header { align-items: flex-start; flex-direction: column; padding: 16px; }
|
|
||||||
.detail-page-header .el-button { width: 100%; min-height: 40px; }
|
|
||||||
.detail-table-scroll :deep(.el-table) { min-width: 560px; }
|
|
||||||
}
|
|
||||||
@media (max-width: 360px) {
|
|
||||||
.order-row-actions { align-items: stretch; flex-direction: column; }
|
|
||||||
.order-row-actions :deep(.el-button) { width: 100%; min-height: 40px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import PlatformFirstLegOrdersPanel from '@/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<PlatformFirstLegOrdersPanel />
|
|
||||||
</template>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { RouteRecordRaw } from 'vue-router'
|
|
||||||
|
|
||||||
export const firstLegOrderRoutes = [
|
|
||||||
{
|
|
||||||
path: 'orders/first-leg',
|
|
||||||
name: 'first-leg-orders',
|
|
||||||
redirect: '/orders',
|
|
||||||
meta: { title: '订单列表', description: '旧头程订单入口已合并到统一订单列表', permission: 'platform:order:list' },
|
|
||||||
},
|
|
||||||
] satisfies RouteRecordRaw[]
|
|
||||||
@@ -16,6 +16,18 @@ function activeCustomer(id: string, tenantName: string, legalName: string | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('InboundTaskList measurement scope', () => {
|
describe('InboundTaskList measurement scope', () => {
|
||||||
|
it('supports selecting only pending measurement reviews for batch approval', () => {
|
||||||
|
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain("key: 'selection', type: 'selection'")
|
||||||
|
expect(source).toContain("row.measureReviewStatus === 'PENDING'")
|
||||||
|
expect(source).toContain('@selection-change="handleReviewSelectionChange"')
|
||||||
|
expect(source).toContain('batchApproveMeasurements')
|
||||||
|
expect(source).toContain('批量审核通过')
|
||||||
|
expect(source).toContain('getMeasurementReviewDetail')
|
||||||
|
expect(source).toContain('approveMeasurement')
|
||||||
|
})
|
||||||
|
|
||||||
it('uses the inbound tenant isolation key for measurement and review requests', () => {
|
it('uses the inbound tenant isolation key for measurement and review requests', () => {
|
||||||
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
|
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Eye, Ruler, SearchCheck } from '@lucide/vue'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { CircleCheckBig, Eye, Ruler, SearchCheck } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||||
@@ -16,6 +17,7 @@ import { listPlatformInbounds } from '../core/api'
|
|||||||
import type { InboundOrderSummary } from '../core/types'
|
import type { InboundOrderSummary } from '../core/types'
|
||||||
import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue'
|
import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue'
|
||||||
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
|
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
|
||||||
|
import { approveMeasurement, getMeasurementReviewDetail } from '../review/api'
|
||||||
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
||||||
import { buildInboundTaskQuery, loadAllActiveCustomers, type InboundTaskMode } from '../task-list'
|
import { buildInboundTaskQuery, loadAllActiveCustomers, type InboundTaskMode } from '../task-list'
|
||||||
|
|
||||||
@@ -42,6 +44,8 @@ const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
|
|||||||
const selectedInbound = ref<InboundOrderSummary | null>(null)
|
const selectedInbound = ref<InboundOrderSummary | null>(null)
|
||||||
const measurementDialogOpen = ref(false)
|
const measurementDialogOpen = ref(false)
|
||||||
const reviewDialogOpen = ref(false)
|
const reviewDialogOpen = ref(false)
|
||||||
|
const batchReviewLoading = ref(false)
|
||||||
|
const selectedReviewRows = ref<InboundOrderSummary[]>([])
|
||||||
let requestSerial = 0
|
let requestSerial = 0
|
||||||
let optionsRequestSerial = 0
|
let optionsRequestSerial = 0
|
||||||
let disposed = false
|
let disposed = false
|
||||||
@@ -73,8 +77,12 @@ const queryFields = computed<QueryField[]>(() => [
|
|||||||
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
|
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
|
||||||
])
|
])
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
const columns = computed<DataTableColumn[]>(() => [
|
||||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||||
|
...(isReview.value && access.can(actionPermission.value) ? [{
|
||||||
|
key: 'selection', type: 'selection', width: 48, fixed: 'left',
|
||||||
|
selectable: (row: InboundOrderSummary) => row.measureReviewStatus === 'PENDING' && !batchReviewLoading.value,
|
||||||
|
} satisfies DataTableColumn] : []),
|
||||||
{ key: 'inboundCode', label: '入仓码', minWidth: 170, slotName: 'inboundCode' },
|
{ key: 'inboundCode', label: '入仓码', minWidth: 170, slotName: 'inboundCode' },
|
||||||
{ key: 'processStatus', label: '流程状态', minWidth: 132, slotName: 'processStatus' },
|
{ key: 'processStatus', label: '流程状态', minWidth: 132, slotName: 'processStatus' },
|
||||||
{ key: 'measureReviewStatus', label: '测量审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
|
{ key: 'measureReviewStatus', label: '测量审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
|
||||||
@@ -88,7 +96,7 @@ const columns: DataTableColumn[] = [
|
|||||||
{ key: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 128, slotName: 'actualGrossWeightKg' },
|
{ key: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 128, slotName: 'actualGrossWeightKg' },
|
||||||
{ key: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 128, slotName: 'actualVolumeCbm' },
|
{ key: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 128, slotName: 'actualVolumeCbm' },
|
||||||
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' },
|
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' },
|
||||||
]
|
])
|
||||||
|
|
||||||
function text(value: unknown) {
|
function text(value: unknown) {
|
||||||
return value === null || value === undefined || value === '' ? '—' : String(value)
|
return value === null || value === undefined || value === '' ? '—' : String(value)
|
||||||
@@ -129,6 +137,7 @@ async function loadOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
|
selectedReviewRows.value = []
|
||||||
const serial = ++requestSerial
|
const serial = ++requestSerial
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
@@ -182,6 +191,48 @@ function handleTaskChanged() {
|
|||||||
void loadTasks()
|
void loadTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleReviewSelectionChange(rows: InboundOrderSummary[]) {
|
||||||
|
selectedReviewRows.value = rows.filter((row) => row.measureReviewStatus === 'PENDING')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchApproveMeasurements() {
|
||||||
|
const selected = [...selectedReviewRows.value]
|
||||||
|
if (!selected.length || batchReviewLoading.value) return
|
||||||
|
batchReviewLoading.value = true
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认批量审核通过已勾选的 ${selected.length} 条测量数据吗?通过后将进入待分柜。`,
|
||||||
|
'批量审核测量数据',
|
||||||
|
{ confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
|
||||||
|
)
|
||||||
|
let successCount = 0
|
||||||
|
let failureCount = 0
|
||||||
|
const failures: string[] = []
|
||||||
|
for (const row of selected) {
|
||||||
|
try {
|
||||||
|
const detail = await getMeasurementReviewDetail(String(row.tenantId), String(row.id))
|
||||||
|
await approveMeasurement(String(row.tenantId), String(row.id), {
|
||||||
|
measurementId: detail.measurementId,
|
||||||
|
expectedVersion: detail.version,
|
||||||
|
})
|
||||||
|
successCount += 1
|
||||||
|
} catch (error) {
|
||||||
|
failureCount += 1
|
||||||
|
failures.push(getApiErrorDetails(error, `${row.inboundCode || row.id} 审核失败`).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadTasks()
|
||||||
|
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 {
|
||||||
|
batchReviewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleMeasurementUpdated() {
|
function handleMeasurementUpdated() {
|
||||||
void loadTasks()
|
void loadTasks()
|
||||||
}
|
}
|
||||||
@@ -246,7 +297,11 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
|||||||
row-key="id"
|
row-key="id"
|
||||||
:empty-text="emptyText"
|
:empty-text="emptyText"
|
||||||
:aria-label="`${taskLabel}待办列表`"
|
:aria-label="`${taskLabel}待办列表`"
|
||||||
|
@selection-change="handleReviewSelectionChange"
|
||||||
>
|
>
|
||||||
|
<template v-if="isReview && access.can(actionPermission)" #toolbar-actions>
|
||||||
|
<el-button type="success" :loading="batchReviewLoading" :disabled="loading || !selectedReviewRows.length" @click="batchApproveMeasurements"><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewRows.length">({{ selectedReviewRows.length }})</span></el-button>
|
||||||
|
</template>
|
||||||
<template #pagination-left>当前显示 {{ result.records.length }} 条 · 共 {{ result.total }} 条</template>
|
<template #pagination-left>当前显示 {{ result.records.length }} 条 · 共 {{ result.total }} 条</template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/components/InboundWorkflowWorkspace.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/components/InboundWorkflowWorkspace.vue'), 'utf8')
|
||||||
|
const measurementDialogSource = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/measurement/InboundMeasurementDialog.vue'), 'utf8')
|
||||||
|
|
||||||
describe('InboundWorkflowWorkspace', () => {
|
describe('InboundWorkflowWorkspace', () => {
|
||||||
it('assembles every phase-two inbound domain behind one server-backed workspace', () => {
|
it('assembles every phase-two inbound domain behind one server-backed workspace', () => {
|
||||||
@@ -88,9 +89,10 @@ describe('InboundWorkflowWorkspace', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('uses semantic outlined or solid buttons for workflow launches and decisions', () => {
|
it('uses semantic outlined or solid buttons for workflow launches and decisions', () => {
|
||||||
expect(source).toContain('<el-button type="primary" @click="measurementDialogOpen = true">')
|
expect(source).toContain('<el-button v-if="canViewMeasurement" type="primary" @click="measurementDialogOpen = true">')
|
||||||
expect(source).toContain('<el-button type="primary" @click="reviewDialogOpen = true">')
|
expect(source).toContain('<el-button type="primary" @click="reviewDialogOpen = true">')
|
||||||
expect(source).toContain('type="primary" plain')
|
expect(measurementDialogSource).toContain('type="primary"')
|
||||||
|
expect(measurementDialogSource).toContain('plain')
|
||||||
expect(source).toContain('type="danger" plain')
|
expect(source).toContain('type="danger" plain')
|
||||||
expect(source).toContain('type="warning" plain')
|
expect(source).toContain('type="warning" plain')
|
||||||
expect(source).toContain('<Pencil :size="16" />编辑测量数据')
|
expect(source).toContain('<Pencil :size="16" />编辑测量数据')
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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/measurement/components/MeasurementEvidenceThumbnail.vue'), 'utf8')
|
||||||
|
const weightWrapperSource = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/weight/components/WeightImageThumbnail.vue'), 'utf8')
|
||||||
|
|
||||||
|
describe('MeasurementEvidenceThumbnail', () => {
|
||||||
|
it('uses the authenticated measurement thumbnail API with stale-request and object-url cleanup', () => {
|
||||||
|
expect(source).toContain("import { downloadWeightThumbnail } from '../../weight/api/weightOcr'")
|
||||||
|
expect(source).toContain('downloadWeightThumbnail(props.customerId, props.inboundId, props.fileId)')
|
||||||
|
expect(source).toContain('currentRequest !== requestId')
|
||||||
|
expect(source).toContain('URL.revokeObjectURL(imageUrl.value)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders explicit loading, failure and missing-file states', () => {
|
||||||
|
expect(source).toContain("fileId?: string | null")
|
||||||
|
expect(source).toContain("!fileId ? emptyText : failed ? '图片加载失败'")
|
||||||
|
expect(source).toContain(':disabled="!fileId"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is also the evidence thumbnail used by the editable OCR workspace', () => {
|
||||||
|
expect(weightWrapperSource).toContain("import MeasurementEvidenceThumbnail from '../../measurement/components/MeasurementEvidenceThumbnail.vue'")
|
||||||
|
expect(weightWrapperSource).toContain('<MeasurementEvidenceThumbnail')
|
||||||
|
expect(weightWrapperSource).toContain('empty-text="未绑定称重图片"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -64,7 +64,7 @@ onBeforeUnmount(() => {
|
|||||||
<img v-if="imageUrl" :src="imageUrl" :alt="alt" loading="lazy" />
|
<img v-if="imageUrl" :src="imageUrl" :alt="alt" loading="lazy" />
|
||||||
<span v-else class="measurement-evidence-thumb__state" :class="{ 'is-loading': loading }">
|
<span v-else class="measurement-evidence-thumb__state" :class="{ 'is-loading': loading }">
|
||||||
<ImageOff v-if="failed || !fileId" :size="22" aria-hidden="true" />
|
<ImageOff v-if="failed || !fileId" :size="22" aria-hidden="true" />
|
||||||
<span>{{ !fileId ? emptyText : failed ? '图片加载失败,点击重试' : '图片加载中' }}</span>
|
<span>{{ !fileId ? emptyText : failed ? '图片加载失败' : '图片加载中' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ describe('MeasurementReviewPanel', () => {
|
|||||||
expect(wrapper.text()).toContain('称重图片')
|
expect(wrapper.text()).toContain('称重图片')
|
||||||
expect(wrapper.text()).toContain('货物重量')
|
expect(wrapper.text()).toContain('货物重量')
|
||||||
expect(wrapper.text()).toContain('110 kg')
|
expect(wrapper.text()).toContain('110 kg')
|
||||||
expect(wrapper.text()).toContain('容量 1.2 m³')
|
expect(wrapper.text()).toContain('托盘类型:木托盘')
|
||||||
|
expect(wrapper.text()).toContain('服务端确认毛重:120 kg')
|
||||||
|
expect(wrapper.text()).toContain('托盘皮重:10 kg')
|
||||||
|
expect(wrapper.text()).not.toContain('TP-001')
|
||||||
expect(wrapper.text()).not.toContain('申报毛重')
|
expect(wrapper.text()).not.toContain('申报毛重')
|
||||||
expect(wrapper.text()).not.toContain('托盘皮重')
|
|
||||||
expect(wrapper.text()).not.toContain('OCR 原文')
|
expect(wrapper.text()).not.toContain('OCR 原文')
|
||||||
expect(wrapper.text()).not.toContain('设备屏幕原始识别内容')
|
expect(wrapper.text()).not.toContain('设备屏幕原始识别内容')
|
||||||
expect(wrapper.text()).not.toContain('置信度')
|
expect(wrapper.text()).not.toContain('置信度')
|
||||||
@@ -126,13 +128,27 @@ describe('MeasurementReviewPanel', () => {
|
|||||||
|
|
||||||
expect(downloadWeightImage).toHaveBeenCalledWith('10001', '90001', 'file-1')
|
expect(downloadWeightImage).toHaveBeenCalledWith('10001', '90001', 'file-1')
|
||||||
const preview = document.body.querySelector('.weight-preview')
|
const preview = document.body.querySelector('.weight-preview')
|
||||||
expect(preview?.textContent).toContain('TP-001 · 木托盘')
|
expect(preview?.textContent).toContain('托盘类型')
|
||||||
|
expect(preview?.textContent).toContain('木托盘')
|
||||||
|
expect(preview?.textContent).not.toContain('TP-001')
|
||||||
expect(preview?.textContent).toContain('120 kg')
|
expect(preview?.textContent).toContain('120 kg')
|
||||||
expect(preview?.textContent).toContain('10 kg')
|
expect(preview?.textContent).toContain('10 kg')
|
||||||
expect(preview?.textContent).toContain('110 kg')
|
expect(preview?.textContent).toContain('110 kg')
|
||||||
expect(preview?.querySelector('img')?.getAttribute('src')).toBe('blob:weight-original')
|
expect(preview?.querySelector('img')?.getAttribute('src')).toBe('blob:weight-original')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the empty pallet type explicitly without falling back to the pallet code', async () => {
|
||||||
|
vi.mocked(getMeasurementReviewDetail).mockResolvedValueOnce({
|
||||||
|
...detail,
|
||||||
|
weightResults: [{ ...detail.weightResults[0], palletType: '' }],
|
||||||
|
})
|
||||||
|
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('托盘类型:未记录类型')
|
||||||
|
expect(wrapper.text()).not.toContain('TP-001')
|
||||||
|
})
|
||||||
|
|
||||||
it('shows the rejected release action only when the permission and rejected state exist', async () => {
|
it('shows the rejected release action only when the permission and rejected state exist', async () => {
|
||||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '重量图片不清晰' })
|
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '重量图片不清晰' })
|
||||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||||
|
|||||||
@@ -288,8 +288,9 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
|
|||||||
<strong>{{ formatNumber(row.netWeightKg, ' kg') }}</strong>
|
<strong>{{ formatNumber(row.netWeightKg, ' kg') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="weight-evidence-card__record">
|
<div class="weight-evidence-card__record">
|
||||||
<span>{{ row.palletCode }} · {{ row.palletType }}</span>
|
<span>托盘类型:{{ row.palletType || '未记录类型' }}</span>
|
||||||
<span>容量 {{ formatNumber(row.palletCapacityCbm, ' m³') }}</span>
|
<span>服务端确认毛重:{{ formatNumber(row.grossWeightKg, ' kg') }}</span>
|
||||||
|
<span>托盘皮重:{{ formatNumber(row.palletTareWeightKg, ' kg') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,8 +331,8 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
|
|||||||
</el-alert>
|
</el-alert>
|
||||||
</div>
|
</div>
|
||||||
<dl class="weight-preview__facts">
|
<dl class="weight-preview__facts">
|
||||||
<div><dt>托盘</dt><dd>{{ previewResult.palletCode }} · {{ previewResult.palletType }}</dd></div>
|
<div><dt>托盘类型</dt><dd>{{ previewResult.palletType || '未记录类型' }}</dd></div>
|
||||||
<div><dt>含托盘重量</dt><dd>{{ formatNumber(previewResult.grossWeightKg, ' kg') }}</dd></div>
|
<div><dt>服务端确认毛重</dt><dd>{{ formatNumber(previewResult.grossWeightKg, ' kg') }}</dd></div>
|
||||||
<div><dt>托盘皮重</dt><dd>{{ formatNumber(previewResult.palletTareWeightKg, ' kg') }}</dd></div>
|
<div><dt>托盘皮重</dt><dd>{{ formatNumber(previewResult.palletTareWeightKg, ' kg') }}</dd></div>
|
||||||
<div><dt>货物重量</dt><dd>{{ formatNumber(previewResult.netWeightKg, ' kg') }}</dd></div>
|
<div><dt>货物重量</dt><dd>{{ formatNumber(previewResult.netWeightKg, ' kg') }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ defineEmits<{ open: [] }>()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MeasurementEvidenceThumbnail
|
<MeasurementEvidenceThumbnail
|
||||||
|
class="weight-thumb"
|
||||||
:customer-id="props.customerId"
|
:customer-id="props.customerId"
|
||||||
:inbound-id="props.inboundId"
|
:inbound-id="props.inboundId"
|
||||||
:file-id="props.fileId"
|
:file-id="props.fileId"
|
||||||
|
|||||||
@@ -221,7 +221,10 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
expect(dialogText).toContain('OCR 识别毛重')
|
expect(dialogText).toContain('OCR 识别毛重')
|
||||||
expect(dialogText).toContain('285.500 KG')
|
expect(dialogText).toContain('285.500 KG')
|
||||||
expect(dialogText).toContain('确认含托盘毛重')
|
expect(dialogText).toContain('确认含托盘毛重')
|
||||||
for (const hiddenLabel of ['托盘编码', '托盘类型', '托盘容量', '识别状态', '置信度', '服务端毛重', '服务端净重', 'OCR 原文']) {
|
expect(dialogText).toContain('托盘类型')
|
||||||
|
expect(dialogText).toContain('木托盘')
|
||||||
|
expect(dialogText).not.toContain('TP-001')
|
||||||
|
for (const hiddenLabel of ['托盘编码', '托盘容量', '识别状态', '置信度', '服务端毛重', '服务端净重', 'OCR 原文']) {
|
||||||
expect(dialogText).not.toContain(hiddenLabel)
|
expect(dialogText).not.toContain(hiddenLabel)
|
||||||
}
|
}
|
||||||
expect(dialogConfirmButton().attributes('disabled')).toBeUndefined()
|
expect(dialogConfirmButton().attributes('disabled')).toBeUndefined()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe('WeightOcrPanel contract', () => {
|
|||||||
expect(source).toContain('class="weight-card__value"')
|
expect(source).toContain('class="weight-card__value"')
|
||||||
expect(source).toContain('v-model="weightDrafts[previewResult.id].value"')
|
expect(source).toContain('v-model="weightDrafts[previewResult.id].value"')
|
||||||
expect(source).toContain('当前托盘皮重')
|
expect(source).toContain('当前托盘皮重')
|
||||||
|
expect(source).toContain("previewResult.palletType || '未记录类型'")
|
||||||
expect(source).toContain('OCR 识别毛重')
|
expect(source).toContain('OCR 识别毛重')
|
||||||
expect(source).toContain('确认含托盘毛重')
|
expect(source).toContain('确认含托盘毛重')
|
||||||
expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)')
|
expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)')
|
||||||
@@ -44,10 +45,12 @@ describe('WeightOcrPanel contract', () => {
|
|||||||
expect(source).not.toContain('<span>置信度</span>')
|
expect(source).not.toContain('<span>置信度</span>')
|
||||||
expect(source).not.toContain('<span>OCR 原文</span>')
|
expect(source).not.toContain('<span>OCR 原文</span>')
|
||||||
expect(source).not.toContain('<span>托盘编码</span>')
|
expect(source).not.toContain('<span>托盘编码</span>')
|
||||||
expect(source).not.toContain('<span>托盘类型</span>')
|
expect(source).toContain('<span>托盘类型</span>')
|
||||||
expect(source).not.toContain('<span>托盘容量</span>')
|
expect(source).not.toContain('<span>托盘容量</span>')
|
||||||
expect(source).not.toContain('<span>服务端毛重</span>')
|
expect(source).not.toContain('<span>服务端毛重</span>')
|
||||||
expect(source).not.toContain('<span>服务端净重</span>')
|
expect(source).not.toContain('<span>服务端净重</span>')
|
||||||
|
expect(source).not.toContain('`${option.palletCode} · ${option.palletType}`')
|
||||||
|
expect(source).toContain("option.palletType || '未记录类型'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gates every action with the implemented permissions and editable statuses', () => {
|
it('gates every action with the implemented permissions and editable statuses', () => {
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
<el-option
|
<el-option
|
||||||
v-for="option in palletOptions"
|
v-for="option in palletOptions"
|
||||||
:key="option.palletId"
|
:key="option.palletId"
|
||||||
:label="`${option.palletCode} · ${option.palletType}`"
|
:label="option.palletType || '未记录类型'"
|
||||||
:value="option.palletId"
|
:value="option.palletId"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -499,10 +499,10 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
@click="openPreview(row)"
|
@click="openPreview(row)"
|
||||||
><strong>{{ cardWeight(row) }}</strong></button>
|
><strong>{{ cardWeight(row) }}</strong></button>
|
||||||
<dl class="weight-card__facts">
|
<dl class="weight-card__facts">
|
||||||
<div><dt>托盘</dt><dd>{{ row.palletCode }}</dd></div>
|
<div><dt>托盘类型</dt><dd>{{ row.palletType || '未记录类型' }}</dd></div>
|
||||||
<div><dt>含托盘毛重</dt><dd>{{ formatWeight(row.grossWeightKg) }}</dd></div>
|
<div><dt>服务端确认毛重</dt><dd>{{ formatWeight(row.grossWeightKg) }}</dd></div>
|
||||||
<div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div>
|
<div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div>
|
||||||
<div><dt>货物净重</dt><dd>{{ formatWeight(row.netWeightKg) }}</dd></div>
|
<div><dt>货物重量</dt><dd>{{ formatWeight(row.netWeightKg) }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -535,6 +535,7 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
<template v-else-if="previewResult">
|
<template v-else-if="previewResult">
|
||||||
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
|
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
|
||||||
<div class="weight-key-facts" aria-label="称重关键数据">
|
<div class="weight-key-facts" aria-label="称重关键数据">
|
||||||
|
<div><span>托盘类型</span><strong>{{ previewResult.palletType || '未记录类型' }}</strong></div>
|
||||||
<div><span>当前托盘皮重</span><strong>{{ formatWeight(previewResult.palletTareWeightKg) }}</strong></div>
|
<div><span>当前托盘皮重</span><strong>{{ formatWeight(previewResult.palletTareWeightKg) }}</strong></div>
|
||||||
<div><span>OCR 识别毛重</span><strong>{{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || 'KG') }}</strong></div>
|
<div><span>OCR 识别毛重</span><strong>{{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || 'KG') }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { http } from '@/shared/api/http'
|
import { http } from '@/shared/api/http'
|
||||||
import {
|
import {
|
||||||
approvePlatformUnifiedOrder,
|
approvePlatformUnifiedOrder,
|
||||||
|
batchApprovePlatformUnifiedOrders,
|
||||||
addPlatformUnifiedOrderNote,
|
addPlatformUnifiedOrderNote,
|
||||||
getPlatformUnifiedOrder,
|
getPlatformUnifiedOrder,
|
||||||
listPlatformUnifiedOrders,
|
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' } })
|
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 () => {
|
it('writes platform internal notes through the unified order boundary', async () => {
|
||||||
vi.mocked(http.post).mockResolvedValue({ data: '91001' })
|
vi.mocked(http.post).mockResolvedValue({ data: '91001' })
|
||||||
await expect(addPlatformUnifiedOrderNote('208', '90001', '已确认船期')).resolves.toBe('91001')
|
await expect(addPlatformUnifiedOrderNote('208', '90001', '已确认船期')).resolves.toBe('91001')
|
||||||
|
|||||||
@@ -388,6 +388,22 @@ export function approvePlatformUnifiedOrder(tenantId: string, orderId: string) {
|
|||||||
).then(unwrapApiResponse)
|
).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) {
|
export function addPlatformUnifiedOrderNote(tenantId: string, orderId: string, content: string) {
|
||||||
return http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(
|
return http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(
|
||||||
`${unifiedOrdersApiPath}/${orderId}/internal-notes`,
|
`${unifiedOrdersApiPath}/${orderId}/internal-notes`,
|
||||||
|
|||||||
@@ -83,15 +83,17 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
|
|
||||||
it('covers real list states and keeps operation column first and left-fixed', () => {
|
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).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('批量审核接口待接入')
|
||||||
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('title="订单列表暂时无法加载"')
|
||||||
expect(source).toContain('empty-text="暂无符合条件的订单"')
|
expect(source).toContain('empty-text="暂无符合条件的订单"')
|
||||||
expect(source).toContain(':loading="loading"')
|
expect(source).toContain(':loading="loading"')
|
||||||
expect(source).toContain('v-model:page="page"')
|
expect(source).toContain('v-model:page="page"')
|
||||||
expect(source).toContain('v-model:page-size="pageSize"')
|
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 = [
|
const expectedColumnKeys = [
|
||||||
'actions', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
|
'actions', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
|
||||||
'destinationWarehouseName', 'totalPackageCount', 'measurementReviewStatus', 'actualGrossWeightKg',
|
'destinationWarehouseName', 'totalPackageCount', 'measurementReviewStatus', 'actualGrossWeightKg',
|
||||||
@@ -218,9 +220,29 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
expect(source).not.toContain("value.replaceAll('_', ' ')")
|
expect(source).not.toContain("value.replaceAll('_', ' ')")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens the measurement image preview at the clicked image index', () => {
|
it('binds dimension and weight evidence to their formal server records without mixing galleries', () => {
|
||||||
expect(source).toContain('v-for="(url, index) in measurementImageUrls"')
|
expect(source).toContain('<el-table :data="measurementDetail?.items || []" row-key="id"')
|
||||||
expect(source).toContain(':initial-index="index"')
|
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', () => {
|
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', () => {
|
it('matches the prototype table grid and distinguishes container identifiers', () => {
|
||||||
expect(source).toContain('row-key="orderId" border stripe')
|
expect(source).toContain('row-key="orderId" border stripe')
|
||||||
expect(source).toContain('<el-table :data="detail.order.cargoes" 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 :data="detail.order.cargoes" border stripe empty-text="暂无客户下单数据"')
|
||||||
expect(source).toContain('--el-table-border-color: #e5e7eb')
|
expect(source).toContain('--el-table-border-color: #e5e7eb')
|
||||||
expect(source).toContain('--el-table-row-hover-bg-color: #f3f7fb')
|
expect(source).toContain('--el-table-row-hover-bg-color: #f3f7fb')
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch, type Component } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
|
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 { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
getPlatformUnifiedOrder,
|
getPlatformUnifiedOrder,
|
||||||
approvePlatformUnifiedOrder,
|
approvePlatformUnifiedOrder,
|
||||||
|
batchApprovePlatformUnifiedOrders,
|
||||||
addPlatformUnifiedOrderNote,
|
addPlatformUnifiedOrderNote,
|
||||||
listPlatformUnifiedOrders,
|
listPlatformUnifiedOrders,
|
||||||
unifiedOrderStatusOptions,
|
unifiedOrderStatusOptions,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
downloadWeightImage,
|
downloadWeightImage,
|
||||||
getInboundMeasurement,
|
getInboundMeasurement,
|
||||||
InboundMeasurementDialog,
|
InboundMeasurementDialog,
|
||||||
|
MeasurementEvidenceThumbnail,
|
||||||
type InboundMeasurementDetail,
|
type InboundMeasurementDetail,
|
||||||
} from '@/modules/inbounds'
|
} from '@/modules/inbounds'
|
||||||
import {
|
import {
|
||||||
@@ -103,6 +105,8 @@ const activeDetailTab = ref('basic')
|
|||||||
const approvalOpen = ref(false)
|
const approvalOpen = ref(false)
|
||||||
const approvalLoadingId = ref('')
|
const approvalLoadingId = ref('')
|
||||||
const approvalSaving = ref(false)
|
const approvalSaving = ref(false)
|
||||||
|
const batchApprovalSaving = ref(false)
|
||||||
|
const selectedApprovalOrders = ref<UnifiedOrderResource[]>([])
|
||||||
const approvalOrder = ref<UnifiedOrderResource | null>(null)
|
const approvalOrder = ref<UnifiedOrderResource | null>(null)
|
||||||
const noteOpen = ref(false)
|
const noteOpen = ref(false)
|
||||||
const noteSaving = ref(false)
|
const noteSaving = ref(false)
|
||||||
@@ -112,14 +116,21 @@ const relationOpen = ref(false)
|
|||||||
const relationOrder = ref<UnifiedOrderResource | null>(null)
|
const relationOrder = ref<UnifiedOrderResource | null>(null)
|
||||||
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
|
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
|
||||||
const measurementLoading = ref(false)
|
const measurementLoading = ref(false)
|
||||||
|
const measurementError = ref<ApiErrorDetails | null>(null)
|
||||||
const measurementLoadedKey = ref('')
|
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 measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
|
||||||
const measurementDialogOpen = ref(false)
|
const measurementDialogOpen = ref(false)
|
||||||
const smartLoadingOpen = ref(false)
|
const smartLoadingOpen = ref(false)
|
||||||
const measurementOrder = ref<UnifiedOrderResource | null>(null)
|
const measurementOrder = ref<UnifiedOrderResource | null>(null)
|
||||||
let detailLoadingKey = ''
|
let detailLoadingKey = ''
|
||||||
let detailRequestSerial = 0
|
let detailRequestSerial = 0
|
||||||
|
let measurementRequestSerial = 0
|
||||||
|
let measurementPreviewRequestSerial = 0
|
||||||
const showAdvancedFilters = ref(false)
|
const showAdvancedFilters = ref(false)
|
||||||
const filters = ref<FilterModel>(emptyFilters())
|
const filters = ref<FilterModel>(emptyFilters())
|
||||||
const appliedFilters = ref<FilterModel>(emptyFilters())
|
const appliedFilters = ref<FilterModel>(emptyFilters())
|
||||||
@@ -208,8 +219,12 @@ const queryFields = computed(() => showAdvancedFilters.value
|
|||||||
? allQueryFields.value
|
? allQueryFields.value
|
||||||
: primaryQueryFields.value)
|
: primaryQueryFields.value)
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
const columns = computed<DataTableColumn[]>(() => [
|
||||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
{ 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: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, slotName: 'inboundCode' },
|
||||||
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 130, slotName: 'mainStatus' },
|
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 130, slotName: 'mainStatus' },
|
||||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
|
{ 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: 'latestInternalNote', prop: 'latestInternalNote', label: '内部备注', minWidth: 180, slotName: 'field', defaultVisible: false },
|
||||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 150, slotName: 'field', defaultVisible: false },
|
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 150, slotName: 'field', defaultVisible: false },
|
||||||
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150, slotName: 'field' },
|
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150, slotName: 'field' },
|
||||||
]
|
])
|
||||||
|
|
||||||
function emptyFilters(): FilterModel {
|
function emptyFilters(): FilterModel {
|
||||||
return {
|
return {
|
||||||
@@ -439,30 +454,53 @@ function measurementTotals(rows: Record<string, unknown>[], customerSource = fal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function revokeMeasurementImages() {
|
function releaseMeasurementPreview() {
|
||||||
measurementImageUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
measurementPreviewRequestSerial += 1
|
||||||
measurementImageUrls.value = []
|
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() {
|
async function loadMeasurementDetails() {
|
||||||
const summary = detail.value?.summary
|
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}`
|
const key = `${summary.tenantId}:${summary.inboundId}`
|
||||||
if (measurementLoadedKey.value === key || measurementLoading.value) return
|
if (measurementLoadedKey.value === key || measurementLoading.value) return
|
||||||
|
const serial = ++measurementRequestSerial
|
||||||
measurementLoading.value = true
|
measurementLoading.value = true
|
||||||
|
measurementError.value = null
|
||||||
try {
|
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
|
measurementLoadedKey.value = key
|
||||||
revokeMeasurementImages()
|
} catch (error) {
|
||||||
const fileIds = [...measurementDetail.value.items.map((item) => item.intelligentImageFileId), ...measurementDetail.value.weightResults.map((item) => item.fileId)]
|
if (serial !== measurementRequestSerial) return
|
||||||
.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 {
|
|
||||||
measurementDetail.value = null
|
measurementDetail.value = null
|
||||||
revokeMeasurementImages()
|
measurementError.value = getApiErrorDetails(error, '测量数据加载失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
measurementLoading.value = false
|
if (serial === measurementRequestSerial) measurementLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +622,7 @@ async function loadWarehouses() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOrders() {
|
async function loadOrders() {
|
||||||
|
selectedApprovalOrders.value = []
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
try {
|
try {
|
||||||
@@ -649,6 +688,51 @@ function openMeasurement(row: UnifiedOrderResource) {
|
|||||||
measurementDialogOpen.value = true
|
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() {
|
function handleMeasurementUpdated() {
|
||||||
void loadOrders()
|
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
|
if (!force && (detailLoadingKey === requestKey || detail.value?.summary.orderId === orderId.value && String(detail.value.summary.tenantId) === tenantId)) return
|
||||||
const serial = ++detailRequestSerial
|
const serial = ++detailRequestSerial
|
||||||
detailLoadingKey = requestKey
|
detailLoadingKey = requestKey
|
||||||
|
measurementRequestSerial += 1
|
||||||
measurementDetail.value = null
|
measurementDetail.value = null
|
||||||
|
measurementError.value = null
|
||||||
measurementLoadedKey.value = ''
|
measurementLoadedKey.value = ''
|
||||||
revokeMeasurementImages()
|
measurementPreviewOpen.value = false
|
||||||
|
releaseMeasurementPreview()
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
detailError.value = null
|
detailError.value = null
|
||||||
try {
|
try {
|
||||||
@@ -747,11 +834,18 @@ watch(detailRouteKey, (value) => {
|
|||||||
detailLoading.value = false
|
detailLoading.value = false
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
watch(measurementPreviewOpen, (open) => {
|
||||||
|
if (!open) releaseMeasurementPreview()
|
||||||
|
})
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadCustomers()
|
void loadCustomers()
|
||||||
void loadWarehouses()
|
void loadWarehouses()
|
||||||
if (!orderId.value) void loadOrders()
|
if (!orderId.value) void loadOrders()
|
||||||
})
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
measurementRequestSerial += 1
|
||||||
|
releaseMeasurementPreview()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -913,15 +1007,20 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="activeDetailTab === 'measurement'">
|
<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>
|
<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">
|
<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>
|
<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 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="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 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>
|
</div>
|
||||||
</section>
|
</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>
|
<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-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>
|
<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">
|
<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>
|
<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-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-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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<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>
|
<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>
|
<template #default>
|
||||||
<div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
|
<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="统一订单列表">
|
<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 v-if="can('platform:firstleg:smart-loading:list')" #toolbar-actions>
|
<template #toolbar-actions>
|
||||||
<el-button type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
|
<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>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
@@ -1054,6 +1197,21 @@ onMounted(() => {
|
|||||||
<el-alert title="确认订单资料无误后审核通过,订单将进入待测量阶段。" type="info" :closable="false" show-icon />
|
<el-alert title="确认订单资料无误后审核通过,订单将进入待测量阶段。" type="info" :closable="false" show-icon />
|
||||||
</AppDialog>
|
</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
|
<InboundMeasurementDialog
|
||||||
v-if="measurementOrder?.inboundId"
|
v-if="measurementOrder?.inboundId"
|
||||||
v-model="measurementDialogOpen"
|
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 { 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 { border-top: 0; border-radius: 0 0 7px 7px; }
|
||||||
.measurement-total strong { margin-right: auto; color: var(--cc-text); font-size: 13px; }
|
.measurement-total strong { margin-right: auto; color: var(--cc-text); font-size: 13px; }
|
||||||
.measurement-media { margin-top: 20px; }
|
.weight-evidence { margin-top: 20px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fbfcfe; }
|
||||||
.measurement-media h4 { margin: 18px 0 10px; color: var(--cc-text); font-size: 14px; }
|
.weight-evidence__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 14px; }
|
||||||
.measurement-media-grid { display: flex; flex-wrap: wrap; gap: 12px; }
|
.weight-evidence__heading h4 { margin: 0; color: var(--cc-text); font-size: 14px; }
|
||||||
.measurement-media-image { width: 110px; height: 90px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
|
.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, .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 { 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: ''; }
|
.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; }
|
.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: 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: 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; } }
|
@media (max-width: 720px) { .weight-evidence__heading { flex-direction: column; } .weight-evidence__heading dl { text-align: left; } }
|
||||||
</style>
|
@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>
|
|
||||||
.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; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user