2026-10-09 11:01:41 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import '@/styles/adminDensity.css'
|
2026-10-18 22:29:06 +08:00
|
|
|
|
import { computed, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
2026-10-11 13:07:52 +08:00
|
|
|
|
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
2026-10-09 11:01:41 +08:00
|
|
|
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
2026-10-16 15:41:56 +08:00
|
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
2026-10-09 11:01:41 +08:00
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
2026-10-15 17:11:03 +08:00
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
|
|
|
|
import { 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 type { PlanningSourceLookup } from './planningApi'
|
|
|
|
|
|
import { appointmentExternalTarget } from './workNavigation'
|
2026-10-18 22:29:06 +08:00
|
|
|
|
import { useWorkSyncRead } from './useWorkSyncRead'
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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}`
|
2026-10-11 13:07:52 +08:00
|
|
|
|
})
|
2026-10-15 17:11:03 +08:00
|
|
|
|
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
|
|
|
|
|
|
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
|
2026-10-18 22:29:06 +08:00
|
|
|
|
// 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()
|
|
|
|
|
|
})
|
2026-10-16 15:41:56 +08:00
|
|
|
|
const panelBody = ref<HTMLElement | null>(null)
|
2026-10-15 19:23:16 +08:00
|
|
|
|
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' })
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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 }
|
2026-10-11 13:07:52 +08:00
|
|
|
|
onBeforeRouteLeave(canLeave)
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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
|
2026-10-18 22:29:06 +08:00
|
|
|
|
const calendarRead = useWorkSyncRead(), summaryRead = useWorkSyncRead(), itemsRead = useWorkSyncRead()
|
|
|
|
|
|
const syncing = computed(() => calendarRead.syncing.value || summaryRead.syncing.value || itemsRead.syncing.value)
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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
|
2026-10-09 11:01:41 +08:00
|
|
|
|
try {
|
2026-10-18 22:29:06 +08:00
|
|
|
|
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)
|
2026-10-15 17:11:03 +08:00
|
|
|
|
if (request === calendarRequest) calendar.value = result
|
|
|
|
|
|
} catch (cause) { if (request === calendarRequest) calendarError.value = getApiErrorDetails(cause, '工作日历读取失败') }
|
|
|
|
|
|
finally { if (request === calendarRequest) calendarLoading.value = false }
|
2026-10-09 11:01:41 +08:00
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
|
async function loadSummary() {
|
|
|
|
|
|
const request = ++summaryRequest; summaryController?.abort(); summaryController = new AbortController(); summaryLoading.value = true; summaryError.value = null
|
2026-10-18 22:29:06 +08:00
|
|
|
|
const signal = summaryController.signal
|
|
|
|
|
|
try { const result = await summaryRead.read(() => getWorkSummary(signal), signal); if (request === summaryRequest) summary.value = result }
|
2026-10-15 17:11:03 +08:00
|
|
|
|
catch (cause) { if (request === summaryRequest) { summaryError.value = getApiErrorDetails(cause, '全局待办读取失败'); summary.value = null } }
|
|
|
|
|
|
finally { if (request === summaryRequest) summaryLoading.value = false }
|
2026-10-09 22:05:51 +08:00
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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
|
2026-10-09 22:05:51 +08:00
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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
|
2026-10-18 22:29:06 +08:00
|
|
|
|
const signal = itemsController.signal
|
|
|
|
|
|
try { const result = await itemsRead.read(() => getWorkItems(query, signal), signal); if (request === itemsRequest) items.value = result }
|
2026-10-15 17:11:03 +08:00
|
|
|
|
catch (cause) { if (request === itemsRequest) itemsError.value = getApiErrorDetails(cause, '工作明细读取失败') }
|
|
|
|
|
|
finally { if (request === itemsRequest) itemsLoading.value = false }
|
2026-10-09 11:01:41 +08:00
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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 }
|
2026-10-09 11:01:41 +08:00
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
|
async function setScope(value: Scope) {
|
|
|
|
|
|
if (!await canLeave()) return
|
|
|
|
|
|
scope.value = value; detailKey.value = null; initialAction.value = null; origins.value = []; sourceLink.value = null; page.value = 1; await loadItems()
|
2026-10-09 11:01:41 +08:00
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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
|
2026-10-09 11:01:41 +08:00
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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()
|
2026-10-11 14:14:49 +08:00
|
|
|
|
}
|
2026-10-16 15:41:56 +08:00
|
|
|
|
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 }
|
2026-10-11 13:27:39 +08:00
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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
|
2026-10-16 15:41:56 +08:00
|
|
|
|
} else { if (origins.value.length) detailKey.value = origins.value.pop()!; initialAction.value = null; sourceLink.value = null }
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
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()
|
2026-10-09 22:05:51 +08:00
|
|
|
|
})
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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 = '货物链接缺少客户或原来源,请从原业务重新打开。'
|
2026-10-09 22:05:51 +08:00
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++referenceRequest; calendarController?.abort(); summaryController?.abort(); itemsController?.abort() })
|
2026-10-09 11:01:41 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
<template>
|
2026-10-15 17:11:03 +08:00
|
|
|
|
<AdminPage class="admin-density appointment-work" spacing="compact">
|
2026-10-15 19:23:16 +08:00
|
|
|
|
<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>
|
2026-10-15 17:11:03 +08:00
|
|
|
|
<p v-if="linkError" role="alert">{{ linkError }}</p>
|
2026-10-18 22:29:06 +08:00
|
|
|
|
<p v-if="syncing" role="status">预约工作清单同步中,正在自动读取最新日历与待办;已保存内容无需重复提交。</p>
|
2026-10-15 17:11:03 +08:00
|
|
|
|
<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>
|
2026-10-15 19:23:16 +08:00
|
|
|
|
<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="全局待办">
|
2026-10-16 15:41:56 +08:00
|
|
|
|
<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>
|
2026-10-15 19:23:16 +08:00
|
|
|
|
</nav>
|
|
|
|
|
|
<LoadErrorState v-if="summaryError" title="全局待办读取失败" :message="summaryError.message" :loading="summaryLoading" @retry="loadSummary"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
|
2026-10-09 11:01:41 +08:00
|
|
|
|
</template>
|
2026-10-15 19:23:16 +08:00
|
|
|
|
</WorkCalendarPanel>
|
2026-10-18 22:29:06 +08:00
|
|
|
|
<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>
|
2026-10-16 15:41:56 +08:00
|
|
|
|
<template #header>
|
2026-10-15 19:23:16 +08:00
|
|
|
|
<header class="panel-header">
|
2026-10-16 15:41:56 +08:00
|
|
|
|
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><h3>{{ panelTitle }}</h3></div>
|
2026-10-15 19:23:16 +08:00
|
|
|
|
<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>
|
2026-10-16 15:41:56 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
<section v-if="panelOpen" class="work-panel" aria-label="工作明细与事项操作">
|
2026-10-15 19:23:16 +08:00
|
|
|
|
<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">
|
2026-10-16 15:41:56 +08:00
|
|
|
|
<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>
|
2026-10-15 19:23:16 +08:00
|
|
|
|
</article>
|
|
|
|
|
|
<p v-if="!items.records.length">当前范围没有工作事项。</p>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
2026-10-16 15:41:56 +08:00
|
|
|
|
</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>
|
2026-10-15 17:11:03 +08:00
|
|
|
|
</div>
|
2026-10-09 11:01:41 +08:00
|
|
|
|
</AdminPage>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
2026-10-15 19:23:16 +08:00
|
|
|
|
.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; }
|
2026-10-16 15:41:56 +08:00
|
|
|
|
.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); }
|
2026-10-15 19:23:16 +08:00
|
|
|
|
.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; }
|
2026-10-16 15:41:56 +08:00
|
|
|
|
: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; }
|
2026-10-15 19:23:16 +08:00
|
|
|
|
.scope-description { margin: var(--admin-space-1) 0; }
|
2026-10-16 15:41:56 +08:00
|
|
|
|
.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); }
|
2026-10-15 19:23:16 +08:00
|
|
|
|
.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); }
|
2026-10-15 19:36:13 +08:00
|
|
|
|
.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; }
|
2026-10-16 15:41:56 +08:00
|
|
|
|
.work-row-content { min-width: 0; }
|
2026-10-15 19:23:16 +08:00
|
|
|
|
.item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-3); }
|
2026-10-16 15:41:56 +08:00
|
|
|
|
.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; }
|
2026-10-15 19:23:16 +08:00
|
|
|
|
.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; }
|
2026-10-16 15:41:56 +08:00
|
|
|
|
.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); }
|
2026-10-15 19:23:16 +08:00
|
|
|
|
.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); }
|
2026-10-15 17:11:03 +08:00
|
|
|
|
.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%); }
|
2026-10-09 11:01:41 +08:00
|
|
|
|
</style>
|