2026-08-13 11:57:44 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
|
|
|
import { useRouter } from 'vue-router'
|
|
|
|
|
|
import { ClipboardCheck, Eye, Ruler, SearchCheck } from '@lucide/vue'
|
|
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
|
|
|
|
|
import { listPlatformWarehouses } from '@/modules/warehouses'
|
|
|
|
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
|
|
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|
|
|
|
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
|
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
|
|
|
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
|
|
|
|
|
import { listPlatformInbounds } from '../core/api'
|
|
|
|
|
|
import type { InboundOrderSummary } from '../core/types'
|
|
|
|
|
|
import InboundMeasurementPanel from '../measurement/InboundMeasurementPanel.vue'
|
|
|
|
|
|
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
|
|
|
|
|
|
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
|
|
|
|
|
import { buildInboundTaskQuery, type InboundTaskMode } from '../task-list'
|
|
|
|
|
|
|
|
|
|
|
|
type FilterModel = QueryModel & {
|
|
|
|
|
|
customerId: string
|
|
|
|
|
|
inboundCode: string
|
|
|
|
|
|
orderNo: string
|
|
|
|
|
|
warehouseId: string
|
|
|
|
|
|
expectedArrivalRange: string[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{ mode: InboundTaskMode }>()
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
|
|
|
|
|
const page = ref(1)
|
|
|
|
|
|
const pageSize = ref(20)
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const loadError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
|
|
|
|
|
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', expectedArrivalRange: [] })
|
|
|
|
|
|
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
|
|
|
|
|
|
const customers = ref<PlatformTenantResource[]>([])
|
|
|
|
|
|
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
|
|
|
|
|
|
const selectedInbound = ref<InboundOrderSummary | null>(null)
|
|
|
|
|
|
const taskDialogOpen = ref(false)
|
|
|
|
|
|
let requestSerial = 0
|
|
|
|
|
|
let disposed = false
|
|
|
|
|
|
|
|
|
|
|
|
const isReview = computed(() => props.mode === 'review')
|
|
|
|
|
|
const actionPermission = computed(() => isReview.value
|
|
|
|
|
|
? 'firstleg:inbound:measurement-review'
|
|
|
|
|
|
: 'platform:firstleg:inbound:measure:view')
|
|
|
|
|
|
const taskLabel = computed(() => isReview.value ? '审核' : '测量')
|
|
|
|
|
|
const emptyText = computed(() => isReview.value ? '暂无待审核的测量数据' : '暂无当前可测量的订单')
|
|
|
|
|
|
const preferenceKey = computed(() => isReview.value ? 'inbound-measurement-reviews' : 'inbound-measurements')
|
|
|
|
|
|
|
|
|
|
|
|
const queryFields = computed<QueryField[]>(() => [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'customerId',
|
|
|
|
|
|
type: 'select',
|
|
|
|
|
|
label: '客户',
|
|
|
|
|
|
width: 260,
|
|
|
|
|
|
placeholder: customerLoadError.value ? '客户列表加载失败' : '全部客户',
|
|
|
|
|
|
options: customers.value.map((customer) => ({
|
|
|
|
|
|
value: String(customer.id),
|
|
|
|
|
|
label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`,
|
|
|
|
|
|
})),
|
|
|
|
|
|
props: { filterable: true, disabled: Boolean(customerLoadError.value) },
|
|
|
|
|
|
},
|
|
|
|
|
|
{ key: 'warehouseId', type: 'select', label: '定位仓库', width: 220, placeholder: '全部仓库', options: warehouseOptions.value, props: { filterable: true } },
|
|
|
|
|
|
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 220, placeholder: '请输入入仓码' },
|
|
|
|
|
|
{ key: 'orderNo', type: 'input', label: '订单号', width: 220, placeholder: '请输入订单号' },
|
|
|
|
|
|
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
const columns: DataTableColumn[] = [
|
|
|
|
|
|
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
|
|
|
|
|
{ key: 'inboundCode', label: '入仓码', minWidth: 170, slotName: 'inboundCode' },
|
|
|
|
|
|
{ key: 'processStatus', label: '流程状态', minWidth: 132, slotName: 'processStatus' },
|
|
|
|
|
|
{ key: 'measureReviewStatus', label: '测量审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
|
|
|
|
|
|
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 164, slotName: 'expectedArrivalAt' },
|
|
|
|
|
|
{ key: 'customerName', label: '客户', minWidth: 160, slotName: 'customerName' },
|
|
|
|
|
|
{ key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' },
|
|
|
|
|
|
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' },
|
|
|
|
|
|
{ key: 'businessType', label: '业务类型', minWidth: 120, slotName: 'businessType' },
|
|
|
|
|
|
{ key: 'channelName', label: '渠道', minWidth: 140, slotName: 'channelName' },
|
|
|
|
|
|
{ key: 'totalPackageCount', label: '总箱数', width: 92, slotName: 'totalPackageCount' },
|
|
|
|
|
|
{ key: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 128, slotName: 'actualGrossWeightKg' },
|
|
|
|
|
|
{ key: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 128, slotName: 'actualVolumeCbm' },
|
|
|
|
|
|
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
function text(value: unknown) {
|
|
|
|
|
|
return value === null || value === undefined || value === '' ? '—' : String(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatNumber(value: number | null | undefined) {
|
|
|
|
|
|
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDate(value: string | null | undefined) {
|
|
|
|
|
|
if (!value) return '—'
|
|
|
|
|
|
const date = new Date(value)
|
|
|
|
|
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function dateParam(value: string | undefined, end = false) {
|
|
|
|
|
|
return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadOptions() {
|
|
|
|
|
|
customerLoadError.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [customerPage, warehousePage] = await Promise.all([
|
|
|
|
|
|
listPlatformTenants({ page: 1, size: 200 }),
|
|
|
|
|
|
listPlatformWarehouses(1, 200),
|
|
|
|
|
|
])
|
|
|
|
|
|
if (disposed) return
|
|
|
|
|
|
customers.value = customerPage.records
|
|
|
|
|
|
warehouseOptions.value = warehousePage.records.map((warehouse) => ({
|
|
|
|
|
|
label: `${warehouse.warehouseName} · ${warehouse.warehouseCode}`,
|
|
|
|
|
|
value: String(warehouse.id),
|
|
|
|
|
|
}))
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (disposed) return
|
|
|
|
|
|
customerLoadError.value = getApiErrorDetails(error, '客户或仓库筛选项加载失败,不影响查看全部待办')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadTasks() {
|
|
|
|
|
|
const serial = ++requestSerial
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
loadError.value = null
|
|
|
|
|
|
const applied = appliedFilters.value
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await listPlatformInbounds(applied.customerId || undefined, buildInboundTaskQuery(props.mode, {
|
|
|
|
|
|
page: page.value,
|
|
|
|
|
|
size: pageSize.value,
|
|
|
|
|
|
warehouseId: applied.warehouseId || undefined,
|
|
|
|
|
|
inboundCode: applied.inboundCode.trim() || undefined,
|
|
|
|
|
|
orderNo: applied.orderNo.trim() || undefined,
|
|
|
|
|
|
expectedArrivalFrom: dateParam(applied.expectedArrivalRange[0]),
|
|
|
|
|
|
expectedArrivalTo: dateParam(applied.expectedArrivalRange[1], true),
|
|
|
|
|
|
}))
|
|
|
|
|
|
if (disposed || serial !== requestSerial) return
|
|
|
|
|
|
result.value = { records: response.records, total: response.total }
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (disposed || serial !== requestSerial) return
|
|
|
|
|
|
loadError.value = getApiErrorDetails(error, `${taskLabel.value}待办加载失败`)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (!disposed && serial === requestSerial) loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyFilters() {
|
|
|
|
|
|
appliedFilters.value = { ...filters.value, expectedArrivalRange: [...filters.value.expectedArrivalRange] }
|
|
|
|
|
|
if (page.value === 1) void loadTasks()
|
|
|
|
|
|
else page.value = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetFilters() {
|
|
|
|
|
|
filters.value = { customerId: '', inboundCode: '', orderNo: '', warehouseId: '', expectedArrivalRange: [] }
|
|
|
|
|
|
appliedFilters.value = { ...filters.value, expectedArrivalRange: [] }
|
|
|
|
|
|
if (page.value === 1) void loadTasks()
|
|
|
|
|
|
else page.value = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openTask(row: InboundOrderSummary) {
|
|
|
|
|
|
selectedInbound.value = row
|
|
|
|
|
|
taskDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openDetail(row: InboundOrderSummary) {
|
|
|
|
|
|
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: { tenantId: row.tenantId } })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleTaskChanged() {
|
|
|
|
|
|
taskDialogOpen.value = false
|
|
|
|
|
|
selectedInbound.value = null
|
|
|
|
|
|
void loadTasks()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch([page, pageSize], () => { void loadTasks() })
|
|
|
|
|
|
onMounted(() => { void loadOptions(); void loadTasks() })
|
|
|
|
|
|
onBeforeUnmount(() => { disposed = true; requestSerial += 1 })
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<AdminPage as="div" class="inbound-task-page">
|
|
|
|
|
|
<ListPageLayout :aria-label="`${taskLabel}待办`">
|
|
|
|
|
|
<template #toolbar>
|
|
|
|
|
|
<QueryToolbar
|
|
|
|
|
|
v-model="filters"
|
|
|
|
|
|
class="surface-panel"
|
|
|
|
|
|
:ariaLabel="`${taskLabel}待办筛选`"
|
|
|
|
|
|
:fields="queryFields"
|
|
|
|
|
|
:preference-key="`${preferenceKey}-query`"
|
|
|
|
|
|
:search-on-reset="false"
|
|
|
|
|
|
@search="applyFilters"
|
|
|
|
|
|
@reset="resetFilters"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-if="customerLoadError || loadError" #state>
|
|
|
|
|
|
<div class="state-stack">
|
|
|
|
|
|
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon>
|
|
|
|
|
|
<template #title><span>{{ customerLoadError.message }}</span></template>
|
|
|
|
|
|
</el-alert>
|
|
|
|
|
|
<LoadErrorState v-if="loadError" :title="`${taskLabel}待办暂时无法加载`" :message="loadError.message" @retry="loadTasks">
|
|
|
|
|
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
|
|
|
|
|
</LoadErrorState>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
|
|
|
|
|
|
<DataTable
|
|
|
|
|
|
v-model:page="page"
|
|
|
|
|
|
v-model:page-size="pageSize"
|
|
|
|
|
|
:data="result.records"
|
|
|
|
|
|
:columns="columns"
|
|
|
|
|
|
:preference-key="`${preferenceKey}-table`"
|
|
|
|
|
|
:loading="loading"
|
|
|
|
|
|
:total="result.total"
|
|
|
|
|
|
:page-sizes="[20, 50, 100]"
|
|
|
|
|
|
row-key="id"
|
|
|
|
|
|
:empty-text="emptyText"
|
|
|
|
|
|
:aria-label="`${taskLabel}待办列表`"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #pagination-left>当前显示 {{ result.records.length }} 条 · 共 {{ result.total }} 条</template>
|
|
|
|
|
|
<template #actions="{ row }">
|
|
|
|
|
|
<div class="row-actions">
|
|
|
|
|
|
<el-button v-if="access.can(actionPermission)" size="small" type="primary" @click="openTask(row as InboundOrderSummary)">
|
|
|
|
|
|
<SearchCheck v-if="isReview" :size="15" />
|
|
|
|
|
|
<Ruler v-else :size="15" />
|
|
|
|
|
|
{{ taskLabel }}
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<el-button v-if="access.can('platform:firstleg:inbound:view')" size="small" plain @click="openDetail(row as InboundOrderSummary)">
|
|
|
|
|
|
<Eye :size="15" />查看
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<span v-if="!access.can(actionPermission) && !access.can('platform:firstleg:inbound:view')" class="muted-action">无操作权限</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template #inboundCode="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
|
|
|
|
|
|
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
|
|
|
|
|
|
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
|
|
|
|
|
|
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
|
|
|
|
|
<template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template>
|
|
|
|
|
|
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
|
|
|
|
|
|
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
|
|
|
|
|
|
<template #businessType="{ row }">{{ text((row as InboundOrderSummary).businessType) }}</template>
|
|
|
|
|
|
<template #channelName="{ row }">{{ text((row as InboundOrderSummary).channelName || (row as InboundOrderSummary).channelCode) }}</template>
|
|
|
|
|
|
<template #totalPackageCount="{ row }">{{ text((row as InboundOrderSummary).totalPackageCount) }}</template>
|
|
|
|
|
|
<template #actualGrossWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualGrossWeightKg) }}</template>
|
|
|
|
|
|
<template #actualVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).actualVolumeCbm) }}</template>
|
|
|
|
|
|
<template #measureCompletedAt="{ row }">{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}</template>
|
|
|
|
|
|
<template #empty><el-empty :description="emptyText" /></template>
|
|
|
|
|
|
</DataTable>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</ListPageLayout>
|
|
|
|
|
|
|
|
|
|
|
|
<el-dialog
|
|
|
|
|
|
v-model="taskDialogOpen"
|
|
|
|
|
|
:title="selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || selectedInbound.id}` : taskLabel"
|
2026-08-13 17:14:51 +08:00
|
|
|
|
width="min(1180px, calc(100vw - 32px))"
|
2026-08-13 11:57:44 +08:00
|
|
|
|
top="4vh"
|
|
|
|
|
|
destroy-on-close
|
|
|
|
|
|
append-to-body
|
|
|
|
|
|
class="inbound-task-dialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
<MeasurementReviewPanel
|
|
|
|
|
|
v-if="isReview && selectedInbound"
|
2026-08-13 17:14:51 +08:00
|
|
|
|
:tenant-id="selectedInbound.tenantId"
|
2026-08-13 11:57:44 +08:00
|
|
|
|
:inbound-id="selectedInbound.id"
|
|
|
|
|
|
@changed="handleTaskChanged"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<InboundMeasurementPanel
|
|
|
|
|
|
v-else-if="selectedInbound"
|
2026-08-13 17:14:51 +08:00
|
|
|
|
:tenant-id="selectedInbound.tenantId"
|
2026-08-13 11:57:44 +08:00
|
|
|
|
:inbound-id="selectedInbound.id"
|
|
|
|
|
|
@changed="handleTaskChanged"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
</AdminPage>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.inbound-task-page { min-width: 0; }
|
|
|
|
|
|
.state-stack { display: grid; gap: 12px; }
|
|
|
|
|
|
.row-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
|
|
|
|
|
.row-actions .el-button { margin: 0; }
|
|
|
|
|
|
.primary-cell { display: grid; gap: 3px; min-width: 0; }
|
|
|
|
|
|
.primary-cell strong, .primary-cell span { overflow-wrap: anywhere; }
|
|
|
|
|
|
.primary-cell span, .muted-action { color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
|
@media (max-width: 520px) {
|
|
|
|
|
|
.row-actions { align-items: stretch; flex-direction: column; }
|
|
|
|
|
|
.row-actions :deep(.el-button) { width: 100%; }
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
|
.inbound-task-dialog { max-height: 92vh; overflow: hidden; }
|
|
|
|
|
|
.inbound-task-dialog .el-dialog__body { max-height: calc(92vh - 62px); overflow: auto; padding: 20px 24px 24px; }
|
|
|
|
|
|
@media (max-width: 520px) {
|
|
|
|
|
|
.inbound-task-dialog .el-dialog__body { padding: 14px; }
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|