Files
admin-web/src/modules/overseas-master-data/pages/OwnedBusinessDetailView.vue
T
2026-10-20 15:48:27 +08:00

83 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import OwnedBusinessDetailLayout from '../components/OwnedBusinessDetailLayout.vue'
import OwnedBusinessOperationsEditor from '../components/OwnedBusinessOperationsEditor.vue'
import OwnedBusinessPhotosEditor from '../components/OwnedBusinessPhotosEditor.vue'
import { getOwnedBusiness, getOwnedBusinessPhotoContent, listOwnedBusinessHistory } from '../api/ownedBusiness'
import { ownedBusinessActionLabels, ownedBusinessStages, ownedBusinessText as text, ownedBusinessPhotoRoles, type OwnedBusinessDetail, type OwnedBusinessHistory, type OwnedBusinessPhoto } from '../ownedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const dispatchNo = computed(() => typeof route.params.dispatchNo === 'string' ? route.params.dispatchNo : '')
const detail = ref<OwnedBusinessDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), activeTab = ref<'task'|'route'|'history'>('task')
const editorOpen = ref(false), photosOpen = ref(false), busy = ref(false)
const history = ref<OwnedBusinessHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null), previewError = ref<ApiErrorDetails | null>(null)
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
const previewUrls = new Set<string>()
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
const taskFields = computed(() => {
const row = detail.value?.row
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], ['提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['实际里程(mile)', row?.operations.actualDistanceMiles ?? '未登记'], ['油耗(L/100km)', row?.operations.fuelConsumptionLitresPer100Km ?? '未登记']]
})
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
function destinationLabel(key: string) { const value = detail.value?.row.destinations.find(destination => destination.destinationKey === key); return value?.warehouseCode || value?.warehouseName || value?.address || '—' }
function photoLabel(photo: OwnedBusinessPhoto) { return ownedBusinessPhotoRoles.find(role => role.slot === photo.slot)?.label || '照片' }
async function load() {
if (!active) return
const request = ++generation, no = dispatchNo.value; loading.value = true; error.value = null
try { if (!no || !access.can(permission.ownedBusinessList)) throw new Error('缺少排车业务号或查看权限'); const value = await getOwnedBusiness(no); if (active && request === generation) { if (value.row.dispatchNo !== no) throw new Error('当前运营资料与排车业务号不一致'); detail.value = value; if (activeTab.value === 'history') void loadHistory() } }
catch (cause) { if (active && request === generation) { detail.value = null; error.value = getApiErrorDetails(cause, '自有业务详情读取失败') } }
finally { if (request === generation) loading.value = false }
}
async function loadHistory() {
if (!active || activeTab.value !== 'history' || !detail.value) return
const request = ++historyGeneration, no = dispatchNo.value; historyLoading.value = true; historyError.value = null
try { const value = await listOwnedBusinessHistory(no, historyPage.value, historySize.value); if (active && request === historyGeneration && no === dispatchNo.value) { history.value = value.records; historyTotal.value = value.total } }
catch (cause) { if (request === historyGeneration) historyError.value = getApiErrorDetails(cause, '操作记录读取失败') }
finally { if (request === historyGeneration) historyLoading.value = false }
}
async function preview(photo: OwnedBusinessPhoto, revision: string | null) {
const no = dispatchNo.value, request = generation; previewError.value = null
if (revision == null) { previewError.value = { message: '历史照片缺少运营资料版本,请重新读取' }; return }
const windowRef = window.open('', '_blank')
if (!windowRef) { previewError.value = { message: '请允许本站打开图片预览窗口' }; return }
try { const blob = await getOwnedBusinessPhotoContent(no, photo, revision); if (!active || request !== generation || no !== dispatchNo.value) { windowRef.close(); return }; const url = URL.createObjectURL(blob); previewUrls.add(url); windowRef.location.href = url }
catch (cause) { windowRef.close(); previewError.value = getApiErrorDetails(cause, '历史照片读取失败') }
}
function saved(value: OwnedBusinessDetail) { if (value.row.dispatchNo !== dispatchNo.value) return; detail.value = value; if (activeTab.value === 'history') void loadHistory() }
watch([activeTab,historyPage,historySize], () => { if (active) void loadHistory() })
watch(dispatchNo, () => { generation++; historyGeneration++; detail.value = null; history.value = []; historyTotal.value = 0; activeTab.value = 'task'; editorOpen.value = false; photosOpen.value = false; if (active) void load() })
onMounted(load)
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
onDeactivated(() => { active = false; generation++; historyGeneration++; loading.value = false; historyLoading.value = false; editorOpen.value = false; photosOpen.value = false })
onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previewUrls.forEach(url => URL.revokeObjectURL(url)) })
</script>
<template>
<OwnedBusinessDetailLayout v-model:active-tab="activeTab" :dispatch-no="detail?.row.dispatchNo || dispatchNo" :status-text="detail ? ownedBusinessStages[detail.row.stage] : ''" :loading="loading" @back="router.push('/overseas-master-data/owned-business')">
<template #actions><el-button v-if="detail && access.can(permission.ownedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.ownedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
<template #state><LoadErrorState v-if="error" title="自有业务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
<template #task><dl v-if="detail" class="density-fields owned-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="owned-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable><section class="owned-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ ownedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><p>里程 {{ row.operations.actualDistanceMiles ?? '未登记' }} mile · 油耗 {{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }} L/100km</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></template>
</OwnedBusinessDetailLayout>
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
<OwnedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
</template>
<style scoped>
.owned-business-fields { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); padding: var(--admin-space-4); }
.owned-business-fields > div { min-width: 0; overflow-wrap: anywhere; }
.owned-business-route { padding: var(--admin-space-4); border-bottom: 1px solid var(--admin-table-border); }
.owned-business-route h3 { font-size: inherit; margin: 0 0 var(--admin-space-3); }
</style>