feat(appointments): restore simplified four-type calendar

This commit is contained in:
2026-10-19 12:20:13 +08:00
parent 8ae1e5d0d4
commit 05cf82dbbc
16 changed files with 622 additions and 860 deletions
@@ -1,303 +1,192 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { addDays, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
import { listArrivalCalendar, type ArrivalCalendarEntry } from './api'
import { getWorkCalendar, getWorkItems, getWorkSummary, workTypes, type WorkAction, type WorkCalendar, type WorkCommand, type WorkCommandResult, type WorkBucket, type WorkFlow, type WorkItemsQuery, type WorkPage, type WorkSummary, type WorkType, type WorkView } from './workApi'
import { flowLabels, typeLabels, viewLabels, workDate, workStatus } from './workPresentation'
import type { LeaveGuard } from './useWorkDraft'
import WorkCalendarPanel from './WorkCalendarPanel.vue'
import WorkDetailPanel from './WorkDetailPanel.vue'
import SourceAppointmentPanel from './SourceAppointmentPanel.vue'
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'
import { useWorkSyncRead } from './useWorkSyncRead'
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const stringQuery = (key: string) => typeof route.query[key] === 'string' ? route.query[key] as string : ''
const savedAnchor = stringQuery('workAnchor')
function initialAnchor() { try { calendarRange(savedAnchor, 'month'); return savedAnchor } catch { return dateKey(new Date()) } }
const anchor = ref(initialAnchor())
const mode = ref<CalendarMode>(stringQuery('workMode') === 'week' ? 'week' : 'month')
const flow = ref<WorkFlow | ''>(['TRANSFER', 'DIRECT', 'UNKNOWN'].includes(stringQuery('workFlow')) ? stringQuery('workFlow') as WorkFlow : '')
const types = ref<WorkType[]>(route.query.workTypes !== undefined ? workTypes.filter(type => stringQuery('workTypes').split(',').includes(type)) : [...workTypes])
const reference = ref(false), arrivals = ref<ArrivalCalendarEntry[]>([]), referenceReady = ref(false)
const calendar = ref<WorkCalendar | null>(null), summary = ref<WorkSummary | null>(null), items = ref<WorkPage | null>(null)
const calendarLoading = ref(false), summaryLoading = ref(false), itemsLoading = ref(false), referenceLoading = ref(false)
const calendarError = ref<ApiErrorDetails | null>(null), summaryError = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), referenceError = ref<ApiErrorDetails | null>(null)
type Scope = { kind: 'global'; view: WorkView } | { kind: 'day'; date: string; types: WorkType[]; flow: WorkFlow | ''; bucket?: WorkBucket } | { kind: 'container'; containerBookingId: string; label: string } | { kind: 'search'; keyword: string } | { kind: 'arrivals'; date: string }
const scope = ref<Scope | null>(null), page = ref(1), detailKey = ref<string | null>(null), initialAction = ref<WorkAction | null>(null)
const origins = ref<string[]>([]), search = ref(''), sourceLink = ref<PlanningSourceLookup | null>(null), linkError = ref('')
const sourceLinkKey = computed(() => sourceLink.value ? `${sourceLink.value.customerId}:${'sourceKey' in sourceLink.value ? sourceLink.value.sourceKey : sourceLink.value.orderId}:${sourceLink.value.destinationKey || ''}` : '')
const navigationError = ref('')
const summaryButtons: Array<{ view: WorkView; field: keyof WorkSummary }> = [{ view: 'ALL', field: 'all' }, { view: 'FOLLOW_TODAY', field: 'followToday' }, { view: 'OVERDUE', field: 'overdue' }, { view: 'UNDATED', field: 'undated' }, { view: 'REVIEW', field: 'review' }]
const buckets: Array<{ key?: WorkBucket; label: string }> = [{ label: '全部' }, { key: 'PENDING', label: '待安排' }, { key: 'CONFIRMED', label: '已落实' }, { key: 'NO_BOOKING_REQUIRED', label: '无需预约' }, { key: 'REVIEW', label: '需复核' }, { key: 'COMPLETED', label: '已完成' }]
const range = computed(() => calendarRange(anchor.value, mode.value))
const heading = computed(() => {
const value = scope.value
if (!value) return ''
if (value.kind === 'global') return viewLabels[value.view]
if (value.kind === 'day') return `${value.date} · ${value.types.length === 1 ? typeLabels[value.types[0]!] : '当天工作'}`
if (value.kind === 'container') return `${value.label} · 本柜待办`
if (value.kind === 'arrivals') return `${value.date} · 到港参考`
return `查找:${value.keyword}`
})
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
// Cached routes must not leave their body-teleported dialog over another workspace.
const pageActive = ref(true)
onActivated(() => { const returning = !pageActive.value; pageActive.value = true; if (returning) void refreshWork() })
onDeactivated(() => {
pageActive.value = false
++calendarRequest; ++summaryRequest; ++itemsRequest
calendarController?.abort(); summaryController?.abort(); itemsController?.abort()
})
const panelBody = ref<HTMLElement | null>(null)
const selectedDate = computed(() => scope.value?.kind === 'day' || scope.value?.kind === 'arrivals' ? scope.value.date : undefined)
const selectedType = computed(() => scope.value?.kind === 'day' && scope.value.types.length === 1 ? scope.value.types[0] : undefined)
const panelTitle = computed(() => sourceLink.value ? '货源与预约' : detailKey.value ? '事项详情' : heading.value)
const listVisible = computed(() => !sourceLink.value && !detailKey.value && scope.value?.kind !== 'arrivals')
watch([detailKey, sourceLinkKey, scope, page], () => { if (panelBody.value) panelBody.value.scrollTop = 0 }, { flush: 'post' })
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) } })
const canLeave: LeaveGuard = async () => { for (const guard of guards) if (!await guard()) return false; return true }
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 } }
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate((to, from) => to.query.sourceKey !== from.query.sourceKey || to.query.orderId !== from.query.orderId || to.query.customerId !== from.query.customerId || to.query.destinationKey !== from.query.destinationKey || to.query.workItem !== from.query.workItem ? canLeave() : true)
let calendarRequest = 0, summaryRequest = 0, itemsRequest = 0, referenceRequest = 0
let calendarController: AbortController | null = null, summaryController: AbortController | null = null, itemsController: AbortController | null = null
const calendarRead = useWorkSyncRead(), summaryRead = useWorkSyncRead(), itemsRead = useWorkSyncRead()
const syncing = computed(() => calendarRead.syncing.value || summaryRead.syncing.value || itemsRead.syncing.value)
async function loadCalendar() {
const request = ++calendarRequest; calendarController?.abort(); calendarController = new AbortController(); calendarError.value = null; calendar.value = null
if (!types.value.length) { calendarLoading.value = false; return }
calendarLoading.value = true
try {
const query = { from: range.value.from, to: addDays(range.value.to, -1), flow: flow.value || undefined, types: [...types.value] }
const signal = calendarController.signal
const result = await calendarRead.read(() => getWorkCalendar(query, signal), signal)
if (request === calendarRequest) calendar.value = result
} catch (cause) { if (request === calendarRequest) calendarError.value = getApiErrorDetails(cause, '工作日历读取失败') }
finally { if (request === calendarRequest) calendarLoading.value = false }
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 loadSummary() {
const request = ++summaryRequest; summaryController?.abort(); summaryController = new AbortController(); summaryLoading.value = true; summaryError.value = null
const signal = summaryController.signal
try { const result = await summaryRead.read(() => getWorkSummary(signal), signal); if (request === summaryRequest) summary.value = result }
catch (cause) { if (request === summaryRequest) { summaryError.value = getApiErrorDetails(cause, '全局待办读取失败'); summary.value = null } }
finally { if (request === summaryRequest) summaryLoading.value = false }
}
function itemQuery(): WorkItemsQuery | null {
const value = scope.value
if (!value || value.kind === 'arrivals') return null
const query: WorkItemsQuery = { view: value.kind === 'global' ? value.view : 'ALL', page: page.value, size: 8 }
if (value.kind === 'day') Object.assign(query, { date: value.date, types: value.types, flow: value.flow || undefined, bucket: value.bucket })
if (value.kind === 'container') query.containerBookingId = value.containerBookingId
if (value.kind === 'search') query.keyword = value.keyword
return query
}
async function loadItems() {
const request = ++itemsRequest; itemsController?.abort(); itemsController = new AbortController(); itemsError.value = null; items.value = null
const query = itemQuery(); if (!query) { itemsLoading.value = false; return }
itemsLoading.value = true
const signal = itemsController.signal
try { const result = await itemsRead.read(() => getWorkItems(query, signal), signal); if (request === itemsRequest) items.value = result }
catch (cause) { if (request === itemsRequest) itemsError.value = getApiErrorDetails(cause, '工作明细读取失败') }
finally { if (request === itemsRequest) itemsLoading.value = false }
}
async function loadArrivals() {
const request = ++referenceRequest; referenceError.value = null; referenceReady.value = false; arrivals.value = []
if (!reference.value) { referenceLoading.value = false; return }
if (!access.can('platform:firstleg:port-schedule:list')) { referenceError.value = { message: '当前账号没有到港参考读取权限,工作事项仍可正常查看。' }; return }
referenceLoading.value = true
try { const result = await listArrivalCalendar({ ...range.value }); if (request === referenceRequest) { arrivals.value = result; referenceReady.value = true } }
catch (cause) { if (request === referenceRequest) referenceError.value = getApiErrorDetails(cause, '到港参考读取失败') }
finally { if (request === referenceRequest) referenceLoading.value = false }
}
async function setScope(value: Scope) {
async function backWork() { if (await canLeave()) { workKey.value = origins.value.pop() || ''; initialAction.value = null } }
async function closeBusiness(done: () => void) {
if (!await canLeave()) return
scope.value = value; detailKey.value = null; initialAction.value = null; origins.value = []; sourceLink.value = null; page.value = 1; await loadItems()
done(); workKey.value = ''; sourceLink.value = null; origins.value = []
await router.replace({ query: { date: selectedDate.value, mode: mode.value } })
}
async function openItem(key: string, action?: WorkAction) {
if (!await canLeave()) return
navigationError.value = ''
if (action && origins.value.includes(key)) { navigationError.value = '前置事项关系需要核对,请返回原事项并刷新后重试。'; return }
if (detailKey.value && detailKey.value !== key) origins.value.push(detailKey.value)
detailKey.value = key; initialAction.value = action || null; sourceLink.value = null
}
async function back() {
if (!await canLeave()) return
const origin = origins.value.pop()
detailKey.value = origin || null; initialAction.value = null; sourceLink.value = null
if (!origin && !scope.value) scope.value = { kind: 'global', view: 'ALL' }
if (!origin) await loadItems()
}
let closingPanel = false
async function closePanel(done: () => void) {
if (closingPanel) return
closingPanel = true
try {
if (!await canLeave()) return
done()
scope.value = null; detailKey.value = null; initialAction.value = null; sourceLink.value = null; origins.value = []; ++itemsRequest; itemsController?.abort()
} finally { closingPanel = false }
}
async function filterCalendar(change: () => void) {
if (!await canLeave()) return
change()
if (scope.value?.kind === 'day' || scope.value?.kind === 'arrivals') { scope.value = null; detailKey.value = null; initialAction.value = null; origins.value = [] }
await Promise.all([loadCalendar(), loadArrivals()])
}
async function toggleReference(value: boolean) { reference.value = value; await loadArrivals() }
async function searchItems() { if (search.value.trim()) await setScope({ kind: 'search', keyword: search.value.trim() }) }
async function changePage(next: number) { page.value = next; await loadItems() }
async function setBucket(bucket?: WorkBucket) { if (scope.value?.kind === 'day') { scope.value = { ...scope.value, bucket }; page.value = 1; await loadItems() } }
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
if (command === 'CONFIRM_ROUTE' && result?.item.containers[0]) {
const container = result.item.containers[0]
scope.value = { kind: 'container', containerBookingId: container.containerBookingId, label: container.containerNo || container.bookingNo || '本柜' }; page.value = 1; detailKey.value = null; origins.value = []; initialAction.value = null
} else { if (origins.value.length) detailKey.value = origins.value.pop()!; initialAction.value = null; sourceLink.value = null }
await Promise.all([loadCalendar(), loadSummary(), loadItems()])
}
async function refreshWork() { await Promise.all([loadCalendar(), loadSummary(), loadItems()]) }
async function external(path: string) {
if (!await canLeave()) return
const value = scope.value
const context = { workAnchor: anchor.value, workMode: mode.value, workFlow: flow.value || undefined, workTypes: types.value.join(','), workItem: detailKey.value || undefined, workOrigins: origins.value.length ? JSON.stringify(origins.value) : undefined, workScope: value?.kind, workView: value?.kind === 'global' ? value.view : undefined, workDate: value?.kind === 'day' ? value.date : undefined, workDayTypes: value?.kind === 'day' ? value.types.join(',') : undefined, workBucket: value?.kind === 'day' ? value.bucket : undefined, workContainer: value?.kind === 'container' ? value.containerBookingId : undefined, workContainerLabel: value?.kind === 'container' ? value.label : undefined, workKeyword: value?.kind === 'search' ? value.keyword : undefined, workPage: String(page.value) }
await router.replace({ query: context })
await router.push(appointmentExternalTarget(path, context))
await router.push(appointmentExternalTarget(path, { date: selectedDate.value, mode: mode.value, workItem: workKey.value || undefined }))
}
onMounted(() => {
const scopeKind = stringQuery('workScope'), restoredView = stringQuery('workView')
if (scopeKind === 'container' && stringQuery('workContainer')) scope.value = { kind: 'container', containerBookingId: stringQuery('workContainer'), label: stringQuery('workContainerLabel') || '本柜' }
else if (scopeKind === 'day' && /^\d{4}-\d{2}-\d{2}$/.test(stringQuery('workDate'))) scope.value = { kind: 'day', date: stringQuery('workDate'), flow: flow.value, types: workTypes.filter(type => stringQuery('workDayTypes').split(',').includes(type)), bucket: buckets.find(bucket => bucket.key === stringQuery('workBucket'))?.key }
else if (scopeKind === 'search') scope.value = { kind: 'search', keyword: stringQuery('workKeyword') }
else if (scopeKind === 'global') scope.value = { kind: 'global', view: summaryButtons.some(button => button.view === restoredView) ? restoredView as WorkView : 'ALL' }
const restoredPage = Number(stringQuery('workPage')); page.value = Number.isInteger(restoredPage) && restoredPage > 0 ? restoredPage : 1
detailKey.value = stringQuery('workItem') || null
try { const savedOrigins: unknown = JSON.parse(stringQuery('workOrigins') || '[]'); if (Array.isArray(savedOrigins) && savedOrigins.every(value => typeof value === 'string' && value.length < 256)) origins.value = savedOrigins.slice(-10) } catch { origins.value = [] }
void loadCalendar(); void loadSummary(); if (scope.value) void loadItems()
})
watch(() => [route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
sourceLink.value = null; linkError.value = ''
const sourceKey = stringQuery('sourceKey'), orderId = stringQuery('orderId'), customerId = stringQuery('customerId'), destinationKey = stringQuery('destinationKey')
if (sourceKey || orderId || customerId || destinationKey) {
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) linkError.value = '当前账号没有货源读取权限,请联系预约负责人处理。'
else if (sourceKey && orderId) linkError.value = '货物链接同时包含两种来源,请从原业务重新打开。'
else if (sourceKey && customerId) sourceLink.value = { sourceKey, customerId, destinationKey: destinationKey || undefined }
else if (orderId && customerId) sourceLink.value = { orderId, customerId, destinationKey: destinationKey || undefined }
else linkError.value = '货物链接缺少客户或原来源,请从原业务重新打开。'
}
}, { immediate: true })
onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++referenceRequest; calendarController?.abort(); summaryController?.abort(); itemsController?.abort() })
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-work" spacing="compact">
<div class="work-toolbar">
<header class="work-heading"><h2 class="density-detail-title">预约工作日历</h2><span>已开船事项 · 中转与直送统一查看</span></header>
<form class="work-search" role="search" @submit.prevent="searchItems"><label class="sr-only" for="appointment-work-search">查找工作事项</label><input id="appointment-work-search" v-model="search" type="search" maxlength="80" placeholder="柜号、送仓批次、车次、目的仓"><button type="submit">查找事项</button><span>跨日期查找未完成事项 · 含未定日期</span></form>
</div>
<p v-if="linkError" role="alert">{{ linkError }}</p>
<p v-if="syncing" role="status">预约工作清单同步中,正在自动读取最新日历与待办;已保存内容无需重复提交。</p>
<LoadErrorState v-if="calendarError" title="工作日历读取失败" :message="calendarError.message" :loading="calendarLoading" @retry="loadCalendar"><template #details><ApiErrorMeta :details="calendarError" /></template></LoadErrorState>
<LoadErrorState v-if="referenceError" title="到港参考不可用" :message="referenceError.message" :loading="referenceLoading" @retry="loadArrivals"><template #details><ApiErrorMeta :details="referenceError" /></template></LoadErrorState>
<div class="work-layout">
<WorkCalendarPanel :selected-date="selectedDate" :selected-type="selectedType" :anchor="anchor" :mode="mode" :flow="flow" :types="types" :calendar="calendar" :loading="calendarLoading" :failed="!!calendarError" :reference="reference" :arrivals="arrivals" :reference-ready="referenceReady" @anchor="offset => filterCalendar(() => anchor = moveCalendar(anchor, mode, offset))" @today="filterCalendar(() => anchor = dateKey(new Date()))" @mode="value => filterCalendar(() => mode = value)" @flow="value => filterCalendar(() => flow = value)" @types="value => filterCalendar(() => types = value)" @reference="toggleReference" @day="(date, type) => setScope({ kind: 'day', date, types: type ? [type] : [...types], flow })" @arrivals="date => setScope({ kind: 'arrivals', date })">
<template #inbox>
<nav class="work-inbox" aria-label="全局待办">
<button v-for="button in summaryButtons" :key="button.view" type="button" :data-view="button.view" :aria-pressed="scope?.kind === 'global' && scope.view === button.view" @click="setScope({ kind: 'global', view: button.view })">{{ viewLabels[button.view] }} <strong :class="{ 'is-zero': !summaryLoading && !summaryError && summary?.[button.field] === 0, 'is-overdue': !summaryLoading && !summaryError && button.view === 'OVERDUE' && (summary?.overdue ?? 0) > 0 }">{{ summaryLoading ? '…' : summaryError ? '加载失败' : summary?.[button.field] ?? '—' }}</strong></button><span>全局待办 · 不受日历筛选影响 · 数量可重叠</span>
</nav>
<LoadErrorState v-if="summaryError" title="全局待办读取失败" :message="summaryError.message" :loading="summaryLoading" @retry="loadSummary"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
</template>
</WorkCalendarPanel>
<AppDialog v-if="pageActive" :model-value="panelOpen" :title="panelTitle" class="admin-density-dialog admin-density appointment-work work-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :close-on-click-modal="true" :close-on-press-escape="true" :before-close="closePanel" :show-footer="listVisible && !!items && !itemsError && !itemsLoading" destroy-on-close>
<template #header>
<header class="panel-header">
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><h3>{{ panelTitle }}</h3></div>
<template v-if="!detailKey && !sourceLink">
<p class="scope-description">{{ scopeDescription }}</p>
<div v-if="scope?.kind === 'day'" class="bucket-buttons"><button v-for="bucket in buckets" :key="bucket.label" type="button" :aria-pressed="scope.bucket === bucket.key" @click="setBucket(bucket.key)">{{ bucket.label }}</button></div>
</template>
</header>
</template>
<section v-if="panelOpen" class="work-panel" aria-label="工作明细与事项操作">
<div ref="panelBody" class="panel-body">
<p v-if="origins.length" class="origin-context">正在处理关联前置事项,完成后可继续原事项。</p>
<p v-if="navigationError" role="alert">{{ navigationError }}</p>
<SourceAppointmentPanel v-if="sourceLink" :key="sourceLinkKey" :source="sourceLink" :can-leave="canLeave" @updated="refreshWork" />
<WorkDetailPanel v-else-if="detailKey" :key="detailKey" :item-key="detailKey" :initial-action="initialAction" :can-leave="canLeave" @open="openItem" @external="external" @saved="saved" @updated="refreshWork" @container="(id, label) => setScope({ kind: 'container', containerBookingId: id, label })" />
<template v-else-if="scope?.kind === 'arrivals'"><p v-for="arrival in arrivals.filter(row => row.arrivalAt.slice(0, 10) === (scope?.kind === 'arrivals' ? scope.date : ''))" :key="arrival.containerBookingId">{{ arrival.containerNo || arrival.cabinetNo || '—' }} · {{ arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' }} {{ arrival.arrivalAt.replace('T', ' ') }}</p><p>到港时间沿用原船节点事实,不用于生成工作安排或实际执行记录。</p></template>
<template v-else>
<LoadErrorState v-if="itemsError" title="工作明细读取失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
<p v-if="itemsLoading" role="status">正在读取明细…</p>
<template v-else-if="items && !itemsError">
<article v-for="item in items.records" :key="item.workItemKey" class="work-row">
<div class="work-row-action"><el-button v-if="item.nextAction" :type="['VIEW_DETAIL', 'VIEW_HISTORY'].includes(item.nextAction.code) ? 'default' : 'primary'" @click="openItem(item.workItemKey, item.nextAction)">{{ item.nextAction.label }}</el-button><el-button v-else @click="openItem(item.workItemKey)">查看详情</el-button></div>
<div class="work-row-content">
<div class="item-heading"><button type="button" class="item-title" @click="openItem(item.workItemKey)">{{ item.title }}</button><span class="item-status">{{ workStatus(item) }}</span></div>
<p class="item-schedule"><span class="item-date">{{ workDate(item) }}</span><span>{{ item.timezone || '地点时区待核实' }}</span></p>
<p class="item-context"><span v-if="!item.title.endsWith(typeLabels[item.type])">{{ typeLabels[item.type] }}</span><span>{{ flowLabels[item.flow] }}</span><span>地点:{{ item.destinationName || item.originName || '地点待核实' }}</span><span>负责人:{{ item.owner?.name || '待分派' }}</span><span v-if="item.followupDate">跟进:{{ item.followupDate }}</span></p>
<p v-for="reason in [...item.overdueReasons, ...item.blockReasons]" :key="reason" class="work-warning">{{ reason }}</p>
</div>
</article>
<p v-if="!items.records.length">当前范围没有工作事项。</p>
</template>
</template>
</div>
</section>
<template #footer><footer v-if="items" class="panel-footer"><span>共 {{ items.total }} 项</span><div v-if="items.total > 8" class="work-pagination"><button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button><span>{{ page }} / {{ Math.max(1, Math.ceil(items.total / 8)) }}</span><button type="button" :disabled="page * 8 >= items.total" @click="changePage(page + 1)">下一页</button></div></footer></template>
</AppDialog>
</div>
<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" @range="load" @select="select" @open="openSource" @edit="edit" />
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
<template v-if="editor">
<p>{{ editor.title }}</p>
<p class="data-notice">自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p>
<label class="arrival-field">计划到仓时间<input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required></label>
<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>
</template>
</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>
.work-toolbar, .work-heading, .work-inbox, .work-search { display: flex; align-items: center; gap: var(--admin-space-3); flex-wrap: wrap; }
.work-toolbar { justify-content: flex-start; column-gap: var(--admin-space-6); }
.work-heading h2 { margin: 0; }
.work-heading span, .work-inbox > span, .work-search > span, .scope-description { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.work-inbox { padding: var(--admin-space-2) var(--admin-space-5); }
.work-inbox button { display: flex; gap: var(--admin-space-3); align-items: center; }
.work-inbox strong { font-variant-numeric: tabular-nums; }
.work-inbox strong.is-zero { color: var(--el-text-color-secondary); font-weight: 400; }
.work-inbox strong.is-overdue { color: var(--el-color-danger-dark-2); }
.appointment-work .work-inbox button[aria-pressed=true] { box-shadow: inset 0 -2px 0 var(--el-color-primary); }
.work-search input { width: 280px; }
.appointment-work button:not(.el-button), .appointment-work input { font: inherit; }
.appointment-work button:not(.el-button) { cursor: pointer; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); padding: 0 var(--admin-space-3); }
.appointment-work button:not(.el-button)[aria-pressed=true] { color: var(--el-color-primary); border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.appointment-work input { min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); }
.appointment-work button:not(.el-button):focus-visible, .appointment-work input:focus-visible, .panel-header h3:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
.work-layout { position: relative; min-width: 0; }
:global(.work-dialog.el-dialog:not(.is-fullscreen)) { height: 88dvh; max-height: 88dvh; margin-top: 6dvh; }
:global(.work-dialog .el-dialog__body) { display: flex; flex: 1; overflow: hidden; }
:global(.work-dialog .app-dialog__body) { display: flex; flex: 1; min-width: 0; }
.work-panel { min-width: 0; min-height: 0; flex: 1; display: flex; flex-direction: column; overflow-wrap: anywhere; }
.panel-header { flex: none; min-width: 0; font-size: var(--admin-font-body); font-weight: 400; }
.panel-nav { display: flex; align-items: center; gap: var(--admin-space-3); }
.panel-header h3 { font-size: var(--admin-font-title); margin: var(--admin-space-2) 0; }
.scope-description { margin: var(--admin-space-1) 0; }
.panel-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 var(--admin-space-1); }
.panel-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); flex-wrap: wrap; flex: none; font-size: var(--admin-font-caption); }
.origin-context { background: var(--el-fill-color-light); padding: var(--admin-space-3); }
.work-row { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: var(--admin-space-3); padding: var(--admin-space-4) 0; border-bottom: 1px solid var(--cc-border); }
.work-row-action .el-button { white-space: normal; width: 100%; min-height: var(--admin-control-height); height: auto; padding: var(--admin-space-2); line-height: 20px; }
.work-row-action :deep(.el-button > span) { min-width: 0; max-width: 100%; white-space: normal; overflow-wrap: anywhere; line-height: inherit; }
.work-row-content { min-width: 0; }
.item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-3); }
.item-status { flex: none; color: var(--el-text-color-regular); background: var(--el-fill-color-light); border-radius: var(--admin-radius); padding: 0 var(--admin-space-2); font-size: var(--admin-font-caption); line-height: 24px; }
.work-row-content p { margin: var(--admin-space-1) 0; color: var(--el-text-color-regular); font-size: var(--admin-font-body); line-height: 1.6; }
.item-schedule, .item-context { display: flex; flex-wrap: wrap; column-gap: var(--admin-space-4); row-gap: 2px; }
.item-date { color: var(--el-text-color-regular); font-variant-numeric: tabular-nums; }
.appointment-work .work-row-content .item-title { border: 0; padding: 0; text-align: left; min-height: 24px; color: var(--el-color-primary); background: transparent; font-weight: 600; }
.work-row-content .work-warning { color: var(--el-color-warning-dark-2); border-left: 2px solid var(--el-color-warning-light-5); padding-left: var(--admin-space-2); }
.work-pagination, .bucket-buttons { display: flex; align-items: center; gap: var(--admin-space-2); flex-wrap: wrap; }
.bucket-buttons { margin-top: var(--admin-space-2); }
.bucket-buttons button { font-size: var(--admin-font-caption); }
.work-panel :deep(.work-form fieldset) { border: 0; margin: 0; padding: 0; min-width: 0; }.work-panel :deep(.work-form label:not(.el-form-item__label)) { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-bottom: var(--admin-space-5); font-size: var(--admin-font-caption); }.work-panel :deep(.work-form input:not(.el-input__inner)), .work-panel :deep(.work-form textarea:not(.el-textarea__inner)), .work-panel :deep(.work-form select) { box-sizing: border-box; width: 100%; font: inherit; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); }.work-panel :deep(.work-form input[type=checkbox]) { width: auto; align-self: flex-start; }.work-panel :deep(.work-form textarea:not(.el-textarea__inner)) { min-height: 64px; resize: vertical; }.work-panel :deep(.appointment-fields), .work-panel :deep(.appointment-fields__extra-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.arrival-field { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-block: var(--admin-space-5); }
.arrival-field input { box-sizing: border-box; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); font: inherit; }
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
</style>