feat(appointment): complete customer receiving and trip continuation workflows

This commit is contained in:
2026-10-16 15:41:56 +08:00
parent 148085286d
commit 45f662ecf2
45 changed files with 2042 additions and 131 deletions
@@ -1,8 +1,9 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
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'
@@ -48,21 +49,11 @@ const heading = computed(() => {
})
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
const panelExpanded = ref(false), panelHeading = ref<HTMLElement | null>(null), panelBody = ref<HTMLElement | null>(null)
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')
let panelTrigger: HTMLElement | null = null
watch(panelOpen, async open => {
if (open) {
panelTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null
await nextTick(); panelHeading.value?.focus()
} else {
panelExpanded.value = false
await nextTick(); if (panelTrigger?.isConnected) panelTrigger.focus()
}
})
watch([detailKey, sourceLinkKey, scope, page], () => { if (panelBody.value) panelBody.value.scrollTop = 0 }, { flush: 'post' })
const guards = new Set<LeaveGuard>()
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => { guards.delete(guard) } })
@@ -131,9 +122,15 @@ async function back() {
if (!origin && !scope.value) scope.value = { kind: 'global', view: 'ALL' }
if (!origin) await loadItems()
}
async function closePanel() {
if (!await canLeave()) return
scope.value = null; detailKey.value = null; initialAction.value = null; sourceLink.value = null; origins.value = []; ++itemsRequest; itemsController?.abort()
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
@@ -149,7 +146,7 @@ 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 { detailKey.value = origins.value.pop() || null; initialAction.value = null; sourceLink.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()]) }
@@ -197,20 +194,22 @@ onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++r
<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>{{ summaryLoading ? '…' : summaryError ? '加载失败' : summary?.[button.field] ?? '—' }}</strong></button><span>不受日历筛选影响 · 数量可重叠</span>
<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>
<aside v-if="panelOpen" class="work-panel" :class="{ 'is-expanded': panelExpanded }" aria-label="工作明细与事项操作">
<AppDialog :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><div class="panel-tools"><button type="button" :aria-expanded="panelExpanded" @click="panelExpanded = !panelExpanded">{{ panelExpanded ? '收起面板' : '展开面板' }}</button><button type="button" aria-label="关闭工作面板" @click="closePanel">关闭 ×</button></div></div>
<h3 ref="panelHeading" tabindex="-1">{{ panelTitle }}</h3>
<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>
@@ -222,15 +221,21 @@ onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++r
<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="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>{{ typeLabels[item.type] }} · {{ workDate(item) }} · {{ item.timezone || '地点时区待核实' }}</p><p>{{ item.destinationName || item.originName || '地点待核实' }} · {{ flowLabels[item.flow] }} · {{ item.owner?.name || '待分派' }}<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>
<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>
<footer v-if="listVisible && items && !itemsError && !itemsLoading" 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>
</aside>
</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>
</template>
@@ -242,6 +247,9 @@ onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++r
.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); }
@@ -249,24 +257,28 @@ onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++r
.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; }
.work-panel { position: absolute; top: 0; right: 0; z-index: 4; width: min(680px, 100%); height: min(720px, calc(100dvh - 260px)); min-height: 320px; box-sizing: border-box; display: flex; flex-direction: column; border: 1px solid var(--cc-border); background: var(--admin-surface); box-shadow: var(--el-box-shadow-light); overflow-wrap: anywhere; }
.work-panel.is-expanded { width: 100%; }
.panel-header { flex: none; padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
.panel-nav, .panel-tools { display: flex; align-items: center; gap: var(--admin-space-3); }
.panel-tools { margin-left: auto; }
.panel-header h3 { font-size: var(--admin-font-section); margin: var(--admin-space-2) 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-5) var(--admin-space-3); }
.panel-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); flex-wrap: wrap; flex: none; padding: var(--admin-space-3) var(--admin-space-5); border-top: 1px solid var(--cc-border); font-size: var(--admin-font-caption); }
.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-secondary); font-size: var(--admin-font-caption); }
.work-row-content p { margin: var(--admin-space-1) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); line-height: 1.6; }
.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); }
.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); }