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

116 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 { 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" 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="owned-business-table" empty-text="当前筛选范围暂无已装车的自有车辆排车单">
<template #toolbar-start><h1>自有业务</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自有业务筛选" preference-key="owned-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 }"><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>
<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)); background: white; border: 1px solid var(--admin-table-border); }
.owned-business-statistics > div { padding: var(--admin-space-4) var(--admin-space-5); border-right: 1px solid var(--admin-table-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(--admin-table-border); padding-top: var(--admin-space-3); }
.owned-business-destination small { display: block; }
</style>