129 lines
13 KiB
Vue
129 lines
13 KiB
Vue
<script setup lang="ts">
|
||||
|
|
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
|||
|
|
import { useRouter } from 'vue-router'
|
|||
|
|
import dayjs from 'dayjs'
|
|||
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|||
|
|
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
|||
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|||
|
|
import QueryToolbar, { type QueryField, type QueryModel, type QueryOption } from '@/shared/components/QueryToolbar.vue'
|
|||
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|||
|
|
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
|||
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|||
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|||
|
|
import OutsourcedBusinessListLayout from '../components/OutsourcedBusinessListLayout.vue'
|
|||
|
|
import OutsourcedBusinessOperationsEditor from '../components/OutsourcedBusinessOperationsEditor.vue'
|
|||
|
|
import OutsourcedBusinessPhotosEditor from '../components/OutsourcedBusinessPhotosEditor.vue'
|
|||
|
|
import { getOutsourcedBusiness, listOutsourcedBusiness } from '../api/outsourcedBusiness'
|
|||
|
|
import { outsourcedBusinessStages, outsourcedBusinessText as text, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessRow, type OutsourcedBusinessStage } from '../outsourcedBusinessTypes'
|
|||
|
|
import { overseasMasterDataPermissions as permission } from '../permissions'
|
|||
|
|
|
|||
|
|
const router = useRouter(), access = useAccessPreviewStore()
|
|||
|
|
const result = ref<OutsourcedBusinessList | null>(null), page = ref(1), size = ref(20), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
|||
|
|
const filters = ref<QueryModel>({ plannedRange: [], keyword: '', stage: '', warehouseId: '' })
|
|||
|
|
const warehouseOptions = ref<QueryOption[]>([]), warehouseError = ref<ApiErrorDetails | null>(null)
|
|||
|
|
const detail = ref<OutsourcedBusinessDetail | null>(null), editorOpen = ref(false), photosOpen = ref(false), opening = ref(false), mutating = ref(false)
|
|||
|
|
let applied: Omit<OutsourcedBusinessQuery,'page'|'size'> = {}, generation = 0, openGeneration = 0, active = true, initialActivation = true
|
|||
|
|
const fields = computed<QueryField[]>(() => [
|
|||
|
|
{ key: 'plannedRange', type: 'daterange', label: '计划发车日期', width: 270, props: { valueFormat: 'YYYY-MM-DD', 'aria-label': '计划发车日期范围' } },
|
|||
|
|
{ key: 'keyword', type: 'input', label: '关键词', width: 230, placeholder: '排车单号 / 车牌 / 目的仓', props: { maxlength: 128 } },
|
|||
|
|
{ key: 'stage', type: 'select', label: '阶段', width: 140, options: Object.entries(outsourcedBusinessStages).map(([value,label]) => ({ value,label })) },
|
|||
|
|
{ key: 'warehouseId', type: 'select', label: '提货仓库', width: 180, options: warehouseOptions.value, props: { disabled: !access.can('platform:warehouse:list') } },
|
|||
|
|
])
|
|||
|
|
const summary = computed(() => [
|
|||
|
|
['排车数', result.value?.summary.dispatchCount], ['装车完成', result.value?.summary.afterLoadingCount], ['派送中', result.value?.summary.deliveringCount], ['派送完成', result.value?.summary.deliveredCount],
|
|||
|
|
])
|
|||
|
|
const columns: DataTableColumn[] = [
|
|||
|
|
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
|
|||
|
|
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
|
|||
|
|
{ key: 'carrier', label: '承运商', minWidth: 150, slotName: 'carrier' },
|
|||
|
|
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
|
|||
|
|
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' },
|
|||
|
|
{ prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
|
|||
|
|
{ key: 'money', label: '整车承运金额 / 单价', minWidth: 170, slotName: 'money' },
|
|||
|
|
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
|
|||
|
|
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
|
|||
|
|
]
|
|||
|
|
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
|
|||
|
|
function rowActions(row: OutsourcedBusinessRow): RowActionItem[] {
|
|||
|
|
return [
|
|||
|
|
{ key: 'detail', label: '查看明细' },
|
|||
|
|
{ key: 'operations', label: '登记运营信息', visible: access.can(permission.outsourcedBusinessEdit), disabled: opening.value || mutating.value || !row.actions.edit.allowed },
|
|||
|
|
{ key: 'photos', label: access.can(permission.outsourcedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片', disabled: opening.value || mutating.value },
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
function act(row: OutsourcedBusinessRow, key: string) {
|
|||
|
|
if (key === 'detail') void router.push(`/overseas-master-data/outsourced-business/${encodeURIComponent(row.dispatchNo)}`)
|
|||
|
|
else if (key === 'operations' || key === 'photos') void openAction(row, key)
|
|||
|
|
}
|
|||
|
|
async function load() {
|
|||
|
|
if (!active) return
|
|||
|
|
const request = ++generation
|
|||
|
|
loading.value = true; error.value = null
|
|||
|
|
try {
|
|||
|
|
if (!access.can(permission.outsourcedBusinessList)) throw new Error('没有外配业务查看权限')
|
|||
|
|
const value = await listOutsourcedBusiness({ ...applied, page: page.value, size: size.value })
|
|||
|
|
if (active && request === generation) result.value = value
|
|||
|
|
} catch (cause) { if (active && request === generation) { result.value = null; error.value = getApiErrorDetails(cause, '外配业务读取失败') } }
|
|||
|
|
finally { if (request === generation) loading.value = false }
|
|||
|
|
}
|
|||
|
|
function search() {
|
|||
|
|
try {
|
|||
|
|
const range = filters.value.plannedRange
|
|||
|
|
const dates = Array.isArray(range) ? range : []
|
|||
|
|
if (dates.length && (dates.length !== 2 || !dayjs(String(dates[0])).isValid() || !dayjs(String(dates[1])).isValid() || dayjs(String(dates[1])).isBefore(dayjs(String(dates[0]))))) throw new Error('请选择有效的计划发车日期范围')
|
|||
|
|
applied = { keyword: String(filters.value.keyword || '').trim() || undefined, stage: (filters.value.stage || undefined) as OutsourcedBusinessStage | undefined, warehouseId: String(filters.value.warehouseId || '') || undefined,
|
|||
|
|
plannedDepartureFrom: dates.length ? dayjs(String(dates[0])).startOf('day').toISOString() : undefined, plannedDepartureTo: dates.length ? dayjs(String(dates[1])).add(1,'day').startOf('day').toISOString() : undefined }
|
|||
|
|
if (page.value !== 1) page.value = 1; else void load()
|
|||
|
|
} catch (cause) { error.value = getApiErrorDetails(cause, '筛选条件无效') }
|
|||
|
|
}
|
|||
|
|
async function loadWarehouses() {
|
|||
|
|
if (!access.can('platform:warehouse:list')) return
|
|||
|
|
try { const values = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }); warehouseOptions.value = values.map(value => ({ value: String(value.id), label: value.warehouseName })) }
|
|||
|
|
catch (cause) { warehouseError.value = getApiErrorDetails(cause, '仓库目录读取失败,可继续使用其他筛选') }
|
|||
|
|
}
|
|||
|
|
async function openAction(row: OutsourcedBusinessRow, kind: 'operations' | 'photos') {
|
|||
|
|
if (opening.value || mutating.value) return
|
|||
|
|
const code = kind === 'operations' ? permission.outsourcedBusinessEdit : permission.outsourcedBusinessList
|
|||
|
|
if (!access.can(code) || kind === 'operations' && !row.actions.edit.allowed) return
|
|||
|
|
const request = ++openGeneration; opening.value = true; error.value = null
|
|||
|
|
try { const value = await getOutsourcedBusiness(row.dispatchNo); if (!active || request !== openGeneration) return; detail.value = value; if (kind === 'operations') editorOpen.value = true; else photosOpen.value = true }
|
|||
|
|
catch (cause) { if (request === openGeneration) error.value = getApiErrorDetails(cause, '当前运营资料读取失败') }
|
|||
|
|
finally { if (request === openGeneration) opening.value = false }
|
|||
|
|
}
|
|||
|
|
function saved(value: OutsourcedBusinessDetail) { detail.value = value; void load() }
|
|||
|
|
watch([page,size], () => { if (active) void load() })
|
|||
|
|
onMounted(() => { void load(); void loadWarehouses() })
|
|||
|
|
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
|
|||
|
|
onDeactivated(() => { active = false; generation++; openGeneration++; loading.value = false; opening.value = false; editorOpen.value = false; photosOpen.value = false })
|
|||
|
|
onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<OutsourcedBusinessListLayout>
|
|||
|
|
<template #statistics><section class="outsourced-business-statistics" aria-label="完整筛选范围外配业务统计" :aria-busy="loading"><div v-for="item in summary" :key="String(item[0])"><span>{{ item[0] }}</span><strong>{{ item[1] ?? '—' }}</strong></div></section></template>
|
|||
|
|
<DataTable appearance="integrated" :stripe="false" v-model:page="page" v-model:page-size="size" :data="result?.records || []" :columns="columns" :total="result?.total || 0" :loading="loading" row-key="dispatchNo" preference-key="outsourced-business-table" empty-text="当前筛选范围暂无已装车的外配车辆排车单">
|
|||
|
|
<template #toolbar-start><h1>外配业务</h1></template>
|
|||
|
|
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="外配业务筛选" preference-key="outsourced-business-query" :loading="loading" @search="search" /><small>日期和时间按浏览器本地时区显示;统计覆盖同一完整筛选范围。</small></template>
|
|||
|
|
<template #notices><LoadErrorState v-if="error" title="外配业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
|
|||
|
|
<template #actions="{ row }"><RowActions :actions="rowActions(row as OutsourcedBusinessRow)" text-actions @action="act(row as OutsourcedBusinessRow, $event)" /></template>
|
|||
|
|
<template #carrier="{ row }">{{ text(row.vehicle.carrierName) }}</template>
|
|||
|
|
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
|
|||
|
|
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
|
|||
|
|
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
|
|||
|
|
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}<br>单价 {{ row.unitPrice ?? '—' }}</template><span v-else>未登记</span></template>
|
|||
|
|
<template #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ outsourcedBusinessStages[row.stage as OutsourcedBusinessStage] }}</template>
|
|||
|
|
</DataTable>
|
|||
|
|
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
|
|||
|
|
<OutsourcedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
|
|||
|
|
</OutsourcedBusinessListLayout>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.outsourced-business-statistics { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); background: white; border: 1px solid var(--admin-table-border); }
|
|||
|
|
.outsourced-business-statistics > div { padding: var(--admin-space-4) var(--admin-space-5); border-right: 1px solid var(--admin-table-border); }
|
|||
|
|
.outsourced-business-statistics > div:last-child { border-right: 0; }
|
|||
|
|
.outsourced-business-statistics strong { display: block; margin-top: var(--admin-space-2); font-size: var(--admin-font-title); }
|
|||
|
|
.outsourced-business-destination + .outsourced-business-destination { margin-top: var(--admin-space-3); border-top: 1px solid var(--admin-table-border); padding-top: var(--admin-space-3); }
|
|||
|
|
.outsourced-business-destination small { display: block; }
|
|||
|
|
</style>
|