206 lines
15 KiB
Vue
206 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { isAxiosError } from 'axios'
|
||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||
import { calendarRange, dateKey, type CalendarMode } from './calendar'
|
||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
|
||
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
|
||
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
|
||
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
|
||
import type { PlanningSourceLookup } from './planningApi'
|
||
import type { WorkAction } from './workApi'
|
||
import { appointmentExternalTarget } from './workNavigation'
|
||
|
||
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
|
||
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
|
||
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
|
||
const AppointmentCreatePanel = defineAsyncComponent(() => import('./AppointmentCreatePanel.vue'))
|
||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||
const creating = ref(false)
|
||
const canCreate = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && access.can('platform:overseas-warehouse:last-mile-order:list'))
|
||
function initialDate() {
|
||
const value = typeof route.query.date === 'string' ? route.query.date : ''
|
||
try { calendarRange(value, 'month'); return value } catch { return dateKey(new Date()) }
|
||
}
|
||
const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mode === 'week' ? 'week' : 'month')
|
||
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
|
||
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
|
||
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
|
||
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
|
||
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
|
||
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
|
||
let generation = 0, controller: AbortController | null = null
|
||
async function load(next = range.value) {
|
||
range.value = next
|
||
const request = ++generation; controller?.abort(); controller = new AbortController()
|
||
const signal = controller.signal
|
||
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
|
||
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
|
||
await Promise.allSettled([
|
||
(async () => {
|
||
let result: CalendarSources | null = null
|
||
try {
|
||
result = await getCalendarSources(next, signal)
|
||
if (request !== generation) return
|
||
sources.value = result
|
||
if (!result.emptyReturnsReadable) notices.value.push('缺少还柜读取权限,当前未显示还柜安排。')
|
||
} catch (cause) { fail('到港与还柜读取失败', cause) }
|
||
if (request !== generation) return
|
||
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) {
|
||
notices.value.push('缺少尾程订单读取权限,当前未显示计划到仓。'); return
|
||
}
|
||
try {
|
||
const rows = await getWarehouseArrivals(next, [...new Set(result?.containers.map(row => row.containerBookingId) || [])], signal)
|
||
if (request === generation) warehouses.value = rows
|
||
} catch (cause) { fail('计划到仓读取失败', cause) }
|
||
})(),
|
||
(async () => {
|
||
if (!access.can('platform:fleet:trip:list')) { notices.value.push('缺少车次读取权限,当前未显示排车安排。'); return }
|
||
try {
|
||
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
||
if (request !== generation) return
|
||
trips.value = result.events
|
||
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 个车次缺少起运仓或时区资料,请到车辆管理核对。`)
|
||
} catch (cause) { fail('排车读取失败', cause) }
|
||
})(),
|
||
])
|
||
if (request === generation) loading.value = false
|
||
}
|
||
function select(date: string, nextMode: CalendarMode) { selectedDate.value = date; mode.value = nextMode }
|
||
async function openSource(event: ScheduleEvent) {
|
||
if (!event.sourcePath || !await canLeave()) return
|
||
const context = { date: selectedDate.value, mode: mode.value }
|
||
await router.replace({ query: context })
|
||
await router.push(appointmentExternalTarget(event.sourcePath, context))
|
||
}
|
||
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
|
||
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
|
||
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
|
||
function edit(event: ScheduleEvent) {
|
||
if (!event.canEdit || !event.warehouse) return
|
||
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
|
||
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
|
||
}
|
||
async function save() {
|
||
const row = editor.value?.warehouse
|
||
if (!row || saving.value || conflict.value || !plannedAt.value) return
|
||
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
|
||
key: `calendar-arrival-${crypto.randomUUID()}` }
|
||
saving.value = true; saveError.value = null
|
||
try {
|
||
await saveWarehouseArrival(pending.id, pending.body, pending.key)
|
||
pending = null; unknownResult.value = false; editor.value = null
|
||
ElMessage.success('计划到仓已保存')
|
||
await load()
|
||
} catch (cause) {
|
||
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
|
||
unknownResult.value = !rejectedCommand(cause)
|
||
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
|
||
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
|
||
if (!unknownResult.value) pending = null
|
||
} finally { saving.value = false }
|
||
}
|
||
async function rereadEditor() {
|
||
const row = editor.value?.warehouse
|
||
if (!row) return
|
||
saving.value = true
|
||
try {
|
||
const rows = await getWarehouseArrivals(range.value, [row.sourceContainerBookingId])
|
||
const latest = rows.find(value => value.sourceContainerBookingId === row.sourceContainerBookingId)
|
||
if (!latest?.canEdit) { saveError.value = { message: '该柜已不能调整计划到仓,请关闭并查看最新货柜记录。' }; return }
|
||
editor.value = { ...editor.value!, warehouse: latest }; conflict.value = false; saveError.value = null
|
||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '最新计划读取失败') }
|
||
finally { saving.value = false }
|
||
}
|
||
const guards = new Set<LeaveGuard>()
|
||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
|
||
async function canLeave() {
|
||
if (saving.value || unknownResult.value) return false
|
||
for (const guard of guards) if (!await guard()) return false
|
||
if (editor.value && plannedAt.value !== originalAt.value) {
|
||
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm' }) }
|
||
catch { return false }
|
||
}
|
||
return true
|
||
}
|
||
async function closeEditor(done: () => void) { if (await canLeave()) { done(); editor.value = null } }
|
||
async function openCreate() { if (canCreate.value && await canLeave()) creating.value = true }
|
||
async function closeCreate(done: () => void) { if (await canLeave()) { done(); creating.value = false } }
|
||
onBeforeRouteLeave(canLeave)
|
||
onBeforeRouteUpdate(canLeave)
|
||
|
||
// 仅承接原业务仍在使用的精确来源/事项深链,不恢复旧全局待办、分页和筛选协议。
|
||
const workKey = ref(''), origins = ref<string[]>([]), sourceLink = ref<PlanningSourceLookup | null>(null), initialAction = ref<WorkAction | null>(null)
|
||
watch(() => [route.query.workItem, route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
|
||
workKey.value = typeof route.query.workItem === 'string' ? route.query.workItem : ''
|
||
const customerId = typeof route.query.customerId === 'string' ? route.query.customerId : ''
|
||
const destinationKey = typeof route.query.destinationKey === 'string' ? route.query.destinationKey : undefined
|
||
sourceLink.value = customerId && typeof route.query.sourceKey === 'string' ? { customerId, sourceKey: route.query.sourceKey, destinationKey }
|
||
: customerId && typeof route.query.orderId === 'string' ? { customerId, orderId: route.query.orderId, destinationKey } : null
|
||
origins.value = []
|
||
initialAction.value = null
|
||
}, { immediate: true })
|
||
async function openWork(key: string, action?: WorkAction) {
|
||
if (await canLeave()) { if (workKey.value) origins.value.push(workKey.value); workKey.value = key; initialAction.value = action || null }
|
||
}
|
||
async function backWork() { if (await canLeave()) { workKey.value = origins.value.pop() || ''; initialAction.value = null } }
|
||
async function closeBusiness(done: () => void) {
|
||
if (!await canLeave()) return
|
||
done(); workKey.value = ''; sourceLink.value = null; origins.value = []
|
||
await router.replace({ query: { date: selectedDate.value, mode: mode.value } })
|
||
}
|
||
async function external(path: string) {
|
||
if (!await canLeave()) return
|
||
await router.push(appointmentExternalTarget(path, { date: selectedDate.value, mode: mode.value, workItem: workKey.value || undefined }))
|
||
}
|
||
onMounted(() => { void load() })
|
||
onActivated(() => { const returning = !active.value; active.value = true; if (returning) void load() })
|
||
onDeactivated(() => { active.value = false; ++generation; controller?.abort() })
|
||
onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||
</script>
|
||
<template>
|
||
<AdminPage class="admin-density appointment-schedule" spacing="compact">
|
||
<LoadErrorState v-for="error in errors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||
<p v-for="notice in notices" :key="notice" role="status" class="data-notice">{{ notice }}</p>
|
||
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" :can-create="canCreate" @create="openCreate" @range="load" @select="select" @open="openSource" @edit="edit" />
|
||
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
|
||
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
|
||
</AppDialog>
|
||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
|
||
<div v-if="editor" class="density-dialog-sections">
|
||
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
|
||
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
|
||
</section>
|
||
|
||
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
|
||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving || unknownResult">
|
||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label><span>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></span></template><el-input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" /></el-form-item></td></tr></tbody></table>
|
||
</el-form>
|
||
</section>
|
||
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
|
||
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
|
||
</div>
|
||
</AppDialog>
|
||
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
|
||
<el-button v-if="origins.length" link @click="backWork">返回原事项</el-button>
|
||
<WorkDetailPanel v-if="workKey" :key="workKey" :item-key="workKey" :initial-action="initialAction" :can-leave="canLeave" @open="openWork" @external="external" @saved="load()" @updated="load()" @container="id => external(`/container-loading/overseas/${encodeURIComponent(id)}`)" />
|
||
<SourceAppointmentPanel v-else-if="sourceLink" :key="JSON.stringify(sourceLink)" :source="sourceLink" :can-leave="canLeave" @updated="load()" />
|
||
</AppDialog>
|
||
</AdminPage>
|
||
</template>
|
||
<style scoped>
|
||
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
|
||
</style>
|