118 lines
12 KiB
Vue
118 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||
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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import OwnedBusinessListLayout from '../components/OwnedBusinessListLayout.vue'
|
||
import OwnedBusinessOperationsEditor from '../components/OwnedBusinessOperationsEditor.vue'
|
||
import OwnedBusinessPhotosEditor from '../components/OwnedBusinessPhotosEditor.vue'
|
||
import { getOwnedBusiness, listOwnedBusiness } from '../api/ownedBusiness'
|
||
import { ownedBusinessStages, ownedBusinessText as text, type OwnedBusinessDetail, type OwnedBusinessList, type OwnedBusinessQuery, type OwnedBusinessRow, type OwnedBusinessStage } from '../ownedBusinessTypes'
|
||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||
|
||
const router = useRouter(), access = useAccessPreviewStore()
|
||
const result = ref<OwnedBusinessList | 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<OwnedBusinessDetail | null>(null), editorOpen = ref(false), photosOpen = ref(false), opening = ref(false), mutating = ref(false)
|
||
let applied: Omit<OwnedBusinessQuery,'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(ownedBusinessStages).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 },
|
||
{ 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' },
|
||
{ key: 'fuel', label: '油耗(L/100km)', minWidth: 145, slotName: 'fuel' }, { 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') : '—' }
|
||
async function load() {
|
||
if (!active) return
|
||
const request = ++generation
|
||
loading.value = true; error.value = null
|
||
try {
|
||
if (!access.can(permission.ownedBusinessList)) throw new Error('没有自有业务查看权限')
|
||
const value = await listOwnedBusiness({ ...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 OwnedBusinessStage | 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: OwnedBusinessRow, kind: 'operations' | 'photos') {
|
||
if (opening.value || mutating.value) return
|
||
const code = kind === 'operations' ? permission.ownedBusinessEdit : permission.ownedBusinessList
|
||
if (!access.can(code) || kind === 'operations' && !row.actions.edit.allowed) return
|
||
const request = ++openGeneration; opening.value = true; error.value = null
|
||
try { const value = await getOwnedBusiness(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: OwnedBusinessDetail) { 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>
|
||
<OwnedBusinessListLayout>
|
||
<template #statistics><section class="owned-business-statistics workbench-panel is-flush" 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>
|
||
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" 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="owned-business-table" empty-text="当前筛选范围暂无已装车且未接入拼车货物的排车单">
|
||
<template #toolbar-start><h1>自有业务<InlineHelp title="查询范围说明">仅统计未实际接入拼车货物的已装车排车,与车辆归属无关;日期和时间按浏览器本地时区显示,统计覆盖同一完整筛选范围。</InlineHelp></h1></template>
|
||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自有业务筛选" preference-key="owned-business-query" :loading="loading" @search="search" /></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 }"><div class="density-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></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="owned-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="owned-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 #fuel="{ row }">{{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }}</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 }">{{ ownedBusinessStages[row.stage as OwnedBusinessStage] }}</template>
|
||
</DataTable></div>
|
||
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
|
||
<OwnedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
|
||
</OwnedBusinessListLayout>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.owned-business-statistics { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); }
|
||
.owned-business-statistics > div { padding: var(--admin-space-4) var(--admin-space-5); border-right: 1px solid var(--workbench-border); }
|
||
.owned-business-statistics > div:last-child { border-right: 0; }
|
||
.owned-business-statistics strong { display: block; margin-top: var(--admin-space-2); font-size: var(--admin-font-title); }
|
||
.owned-business-destination + .owned-business-destination { margin-top: var(--admin-space-3); border-top: 1px solid var(--workbench-border); padding-top: var(--admin-space-3); }
|
||
.owned-business-destination small { display: block; }
|
||
</style>
|