310 lines
14 KiB
Vue
310 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
|
import { Eye, Pencil } from '@lucide/vue'
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
import AppDialog from '@/shared/components/AppDialog.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 StatusBadge from '@/shared/components/StatusBadge.vue'
|
|
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
|
|
import {
|
|
getConfirmedLoadingList,
|
|
getPreloadPlan,
|
|
listConfirmedLoadingLists,
|
|
listPreloadPlans,
|
|
updatePreloadTransport,
|
|
type ConfirmedSummary,
|
|
type LoadingDetail,
|
|
type LoadingListMode,
|
|
type LoadingListQuery,
|
|
type LoadingListSummary,
|
|
type PreloadSummary,
|
|
} from '../api/platformLoadingLists'
|
|
|
|
const props = defineProps<{ mode: LoadingListMode }>()
|
|
const access = useAccessPreviewStore()
|
|
const rows = ref<LoadingListSummary[]>([])
|
|
const loading = ref(false)
|
|
const error = ref<ApiErrorDetails | null>(null)
|
|
const page = ref(1)
|
|
const pageSize = ref(20)
|
|
const total = ref(0)
|
|
const query = reactive<QueryModel>({ keyword: '', createdRange: [] })
|
|
const detailOpen = ref(false)
|
|
const detailLoading = ref(false)
|
|
const detailError = ref<ApiErrorDetails | null>(null)
|
|
const detail = ref<LoadingDetail | null>(null)
|
|
const selectedPlanId = ref('')
|
|
const selectedWarehouseId = ref('')
|
|
const editOpen = ref(false)
|
|
const editSaving = ref(false)
|
|
const editError = ref<ApiErrorDetails | null>(null)
|
|
const editFormRef = ref<FormInstance>()
|
|
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
|
|
|
|
const isPreload = computed(() => props.mode === 'preload')
|
|
const title = computed(() => isPreload.value ? '预装清单' : '装柜清单')
|
|
const listPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:list' : 'platform:firstleg:loading-list:list')
|
|
const viewPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:view' : 'platform:firstleg:loading-list:view')
|
|
const canList = computed(() => access.can(listPermission.value))
|
|
const canView = computed(() => access.can(viewPermission.value))
|
|
const canEdit = computed(() => isPreload.value && access.can('platform:firstleg:preload:edit'))
|
|
const fields = computed<QueryField[]>(() => [
|
|
{ key: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/集装箱号/柜号' : '装柜清单号/集装箱号/柜号', width: 250 },
|
|
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 },
|
|
])
|
|
|
|
const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
|
|
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
|
|
{ key: 'preloadNo', prop: 'preloadNo', label: '预装单号', minWidth: 165 },
|
|
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
|
|
{ key: 'internalContainerNo', prop: 'internalContainerNo', label: '柜号', minWidth: 130 },
|
|
{ key: 'customers', label: '客户', minWidth: 190, slotName: 'customers' },
|
|
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
|
|
{ key: 'vesselVoyage', label: '船名 / 航次', minWidth: 175, slotName: 'vesselVoyage' },
|
|
{ key: 'containerType', prop: 'containerType', label: '箱型', width: 100 },
|
|
{ key: 'status', label: '状态', width: 108, slotName: 'status' },
|
|
{ key: 'skuCount', prop: 'skuCount', label: 'SKU 数', width: 90, align: 'right' },
|
|
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' },
|
|
{ key: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right', slotName: 'volume' },
|
|
{ key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 125, align: 'right', slotName: 'grossWeight' },
|
|
{ key: 'createdAt', label: '创建时间', minWidth: 155, slotName: 'createdAt' },
|
|
] : [
|
|
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
|
|
{ key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 },
|
|
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
|
|
{ key: 'internalContainerNo', prop: 'internalContainerNo', label: '柜号', minWidth: 130 },
|
|
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
|
|
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
|
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' },
|
|
{ key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 125, align: 'right', slotName: 'grossWeight' },
|
|
{ key: 'totalNetWeightKg', label: '总净重(kg)', width: 125, align: 'right', slotName: 'netWeight' },
|
|
{ key: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right', slotName: 'volume' },
|
|
{ key: 'totalQuantity', prop: 'totalQuantity', label: '产品总数', width: 105, align: 'right' },
|
|
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
|
|
{ key: 'createdByName', prop: 'createdByName', label: '创建人', minWidth: 120 },
|
|
{ key: 'createdAt', label: '创建时间', minWidth: 155, slotName: 'createdAt' },
|
|
])
|
|
|
|
const editRules: FormRules = {
|
|
containerNo: [{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' }],
|
|
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
|
|
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
|
|
}
|
|
|
|
function formatDateTime(value?: string | null) {
|
|
return value ? value.replace('T', ' ').slice(0, 16) : '—'
|
|
}
|
|
|
|
function formatNumber(value?: number | null, digits = 3) {
|
|
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
|
|
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: digits })
|
|
}
|
|
|
|
function toQuery(): LoadingListQuery {
|
|
const range = Array.isArray(query.createdRange) ? query.createdRange.map(String) : []
|
|
return {
|
|
page: page.value,
|
|
size: pageSize.value,
|
|
keyword: String(query.keyword || '').trim() || undefined,
|
|
createdFrom: range[0] ? `${range[0]}T00:00:00` : undefined,
|
|
createdTo: range[1] ? `${range[1]}T23:59:59` : undefined,
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
if (!canList.value) return
|
|
const request = toQuery()
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
const result = isPreload.value ? await listPreloadPlans(request) : await listConfirmedLoadingLists(request)
|
|
rows.value = result.records
|
|
total.value = result.total
|
|
} catch (cause) {
|
|
error.value = getApiErrorDetails(cause, `${title.value}加载失败,请稍后重试`)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function search() {
|
|
if (page.value !== 1) page.value = 1
|
|
else void load()
|
|
}
|
|
|
|
|
|
async function loadDetail() {
|
|
if (!selectedPlanId.value || !selectedWarehouseId.value || !canView.value) return
|
|
detailLoading.value = true
|
|
detailError.value = null
|
|
try {
|
|
detail.value = isPreload.value
|
|
? await getPreloadPlan(selectedPlanId.value, selectedWarehouseId.value)
|
|
: await getConfirmedLoadingList(selectedPlanId.value, selectedWarehouseId.value)
|
|
} catch (cause) {
|
|
detailError.value = getApiErrorDetails(cause, '清单详情加载失败,请稍后重试')
|
|
} finally {
|
|
detailLoading.value = false
|
|
}
|
|
}
|
|
|
|
function openDetail(rowValue: object) {
|
|
const row = rowValue as LoadingListSummary
|
|
selectedPlanId.value = String(row.planId)
|
|
selectedWarehouseId.value = String(row.warehouseId || '')
|
|
detail.value = null
|
|
detailError.value = null
|
|
detailOpen.value = true
|
|
void loadDetail()
|
|
}
|
|
|
|
async function openEditFromRow(rowValue: object) {
|
|
const row = rowValue as LoadingListSummary
|
|
if (!isPreload.value || !canEdit.value || row.status !== 'LOADING') return
|
|
selectedPlanId.value = String(row.planId)
|
|
selectedWarehouseId.value = String(row.warehouseId || '')
|
|
detail.value = null
|
|
detailError.value = null
|
|
detailOpen.value = true
|
|
await loadDetail()
|
|
const loadedDetail = detail.value as LoadingDetail | null
|
|
if (loadedDetail && loadedDetail.summary.status === 'LOADING') openEdit()
|
|
}
|
|
|
|
function openEdit() {
|
|
if (!detail.value || !('preloadNo' in detail.value.summary) || detail.value.summary.status !== 'LOADING') return
|
|
editForm.containerNo = detail.value.summary.containerNo || ''
|
|
editForm.voyageNo = detail.value.summary.voyageNo || ''
|
|
editForm.operationReason = ''
|
|
editError.value = null
|
|
editOpen.value = true
|
|
}
|
|
|
|
async function saveTransport() {
|
|
if (!detail.value || !('preloadNo' in detail.value.summary)) return
|
|
const valid = await editFormRef.value?.validate().catch(() => false)
|
|
if (!valid) return
|
|
editSaving.value = true
|
|
editError.value = null
|
|
try {
|
|
detail.value = await updatePreloadTransport(selectedPlanId.value, selectedWarehouseId.value, {
|
|
containerNo: editForm.containerNo.trim() || null,
|
|
voyageNo: editForm.voyageNo.trim() || null,
|
|
operationReason: editForm.operationReason.trim() || null,
|
|
expectedVersion: detail.value.summary.version,
|
|
})
|
|
editOpen.value = false
|
|
ElMessage.success('运输信息已更新')
|
|
await load()
|
|
} catch (cause) {
|
|
editError.value = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
|
|
} finally {
|
|
editSaving.value = false
|
|
}
|
|
}
|
|
|
|
watch([page, pageSize], () => void load())
|
|
onMounted(() => {
|
|
void load()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<AdminPage>
|
|
<div class="business-page loading-lists-page">
|
|
<ListPageLayout :ariaLabel="title">
|
|
<template #toolbar>
|
|
<QueryToolbar
|
|
v-model="query"
|
|
:ariaLabel="`${title}筛选`"
|
|
:fields="fields"
|
|
:available-fields="fields"
|
|
:preference-key="`loading-lists-${mode}-query`"
|
|
@search="search"
|
|
/>
|
|
</template>
|
|
|
|
<template v-if="!canList || error" #state>
|
|
<LoadErrorState v-if="!canList" :title="`无权查看${title}`" message="当前账号缺少该清单的列表权限。" :show-retry="false" />
|
|
<LoadErrorState v-else-if="error" :title="`${title}加载失败`" :message="error.message" :loading="loading" @retry="load">
|
|
<template #details><ApiErrorMeta :details="error" /></template>
|
|
</LoadErrorState>
|
|
</template>
|
|
<section v-if="canList && !error" class="surface-panel table-panel">
|
|
<DataTable
|
|
v-model:page="page"
|
|
v-model:page-size="pageSize"
|
|
:data="rows"
|
|
:columns="columns"
|
|
:loading="loading"
|
|
:total="total"
|
|
:preference-key="`loading-lists-${mode}`"
|
|
row-key="planId"
|
|
:empty-text="`暂无符合筛选条件的${title}`"
|
|
:aria-label="title"
|
|
>
|
|
<template #actions="{ row }">
|
|
<el-button v-if="canView" link type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button>
|
|
<el-button v-if="isPreload && canEdit && row.status === 'LOADING'" link type="primary" @click="openEditFromRow(row)"><Pencil :size="15" />编辑</el-button>
|
|
</template>
|
|
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || item.accountId).join('、') || '—' }}</template>
|
|
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
|
|
<template #status="{ row }"><StatusBadge :status="row.status" /><span class="status-label">{{ row.status === 'LOADING' ? '装柜中' : row.status === 'COMPLETED' ? '装柜完成' : row.status }}</span></template>
|
|
<template #grossWeight="{ row }">{{ formatNumber(row.totalGrossWeightKg) }}</template>
|
|
<template #netWeight="{ row }">{{ formatNumber((row as ConfirmedSummary).totalNetWeightKg) }}</template>
|
|
<template #volume="{ row }">{{ formatNumber(row.totalVolumeCbm) }}</template>
|
|
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
|
</DataTable>
|
|
</section>
|
|
</ListPageLayout>
|
|
|
|
<LoadingListDetailDrawer
|
|
v-model="detailOpen"
|
|
:mode="mode"
|
|
:loading="detailLoading"
|
|
:detail="detail"
|
|
:error="detailError"
|
|
:can-edit="canEdit"
|
|
@retry="loadDetail"
|
|
@edit="openEdit"
|
|
/>
|
|
|
|
<AppDialog
|
|
v-model="editOpen"
|
|
title="编辑预装运输信息"
|
|
confirm-text="保存"
|
|
:confirm-loading="editSaving"
|
|
:confirm-disabled="!canEdit"
|
|
@confirm="saveTransport"
|
|
>
|
|
<el-alert type="info" :closable="false" show-icon title="仅装柜中允许修改;保存时按详情版本执行乐观锁校验。" />
|
|
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
|
|
<el-form-item label="集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" show-word-limit placeholder="请输入集装箱号" /></el-form-item>
|
|
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" show-word-limit placeholder="请输入航次" /></el-form-item>
|
|
<el-form-item label="修改原因" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填,填写后将记录在操作历史中" /></el-form-item>
|
|
</el-form>
|
|
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false">
|
|
<template #details><ApiErrorMeta :details="editError" /></template>
|
|
</LoadErrorState>
|
|
</AppDialog>
|
|
</div>
|
|
</AdminPage>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.loading-lists-page { display: grid; min-width: 0; gap: 16px; }
|
|
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
|
.status-label { color: var(--cc-muted); font-size: 12px; }
|
|
.status-label { margin-left: 6px; }
|
|
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 16px; }
|
|
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
|
@media (max-width: 640px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
|
</style>
|