Files
admin-web/src/modules/appointment-calendar/WorkDetailPanel.vue
T
2026-10-19 17:56:31 +08:00

200 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getWorkDetail, type WorkAction, type WorkDetail, type WorkCommand, type WorkCommandResult } from './workApi'
import { actionAllowed, actionRoute, referenceRoute, commandName, flowLabels, typeLabels, workDate, workStatus, workReason, sameAction } from './workPresentation'
import CargoVerificationForm from './CargoVerificationForm.vue'
import WorkCommandForm from './WorkCommandForm.vue'
import WorkAppointmentForm from './WorkAppointmentForm.vue'
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
import WorkDispatchForm from './WorkDispatchForm.vue'
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
import WorkReceiptPlans from './WorkReceiptPlans.vue'
import AppointmentBatchForm from './AppointmentBatchForm.vue'
import WorkReceivingPanel from './WorkReceivingPanel.vue'
import WorkBatchContinuation from './WorkBatchContinuation.vue'
import type { FbaAppointmentDto } from '@/modules/overseas-warehouse'
import type { LeaveGuard } from './useWorkDraft'
import { useWorkSyncRead } from './useWorkSyncRead'
const props = defineProps<{ itemKey: string; initialAction?: WorkAction | null; canLeave: LeaveGuard }>()
const emit = defineEmits<{ open: [key: string, action?: WorkAction]; external: [path: string]; saved: [result?: WorkCommandResult, command?: WorkCommand['command']]; updated: []; container: [id: string, label: string] }>()
const detail = ref<WorkDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), action = ref<WorkAction | null>(null)
const attentionReasons = computed(() => [...new Set([
...(detail.value?.item.blockReasons || []),
...(detail.value?.item.missingFields || []),
...(detail.value?.item.overdueReasons || []),
].map(workReason))])
const needsFollowup = computed(() => !!detail.value && detail.value.item.executionStatus !== 'COMPLETED' && (!detail.value.item.owner || !detail.value.item.followupDate))
const historyLabels: Record<string, string> = { BASELINE_OBSERVED: '同步原业务状态', GENERATED: '创建工作事项', CONFIRM_ROUTE: '确认路线', SAVE_FOLLOWUP: '记录跟进', RESOLVE_REVIEW: '处理复核', SAVE_REQUIREMENT: '保存预约要求', HANDOFF_RETURN: '交接还空责任', ACCEPT_RETURN: '确认接手还空' }
function historyTime(value: string) { return value.replace('T', ' ').replace(/\.\d+(?=Z|[+-]\d{2}:\d{2}$)/, '').replace(/Z$/, ' UTC') }
const receivingOwners = computed(() => [detail.value?.item.owner, ...(detail.value?.relatedItems.map(item => item.owner) || [])].filter((person): person is { id: string; name: string } => !!person?.id))
const actionNotice = ref('')
const saveCompleted = ref(false)
const creatingFromCargo = ref(false)
const batchKey = ref<string | null>(null), openedAppointmentNo = ref<string | null>(null)
const receivingKey = ref<string | null>(null)
const continuation = ref<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> } | null>(null)
let controller: AbortController | null = null, request = 0
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
async function load() {
const generation = ++request; controller?.abort(); controller = new AbortController(); loading.value = true; error.value = null
const signal = controller.signal
try { const result = await syncRead.read(() => getWorkDetail(props.itemKey, signal), signal); if (generation === request) detail.value = result }
catch (cause) { if (generation === request) error.value = getApiErrorDetails(cause, '工作事项读取失败') }
finally { if (generation === request) loading.value = false }
}
const fbaNumber = computed(() => {
const target = action.value?.target
if (target?.kind !== 'FBA_APPOINTMENT' || !target.appointmentId) return null
return detail.value?.references.find(ref => ref.kind === 'FBA_APPOINTMENT' && ref.key === target.batchKey)?.businessNo || null
})
const cargoSource = computed(() => {
const target = action.value?.target
return target?.kind === 'CARGO_VERIFICATION' ? { ...target.source, destinationKey: target.source.destinationKey || undefined } : null
})
const createSources = computed(() => creatingFromCargo.value && action.value?.target.kind === 'CARGO_VERIFICATION' ? [action.value.target.source] : action.value?.target.kind === 'FBA_APPOINTMENT' && !action.value.target.appointmentId && ['SAVE_PLAN', 'REQUEST_BOOKING', 'RECORD_CONFIRMATION'].includes(action.value.code) ? action.value.target.sources : null)
const dispatchBatchKeys = computed(() => {
const target = action.value?.target
if (target?.kind !== 'FLEET_TRIP' || target.tripId || action.value?.code !== 'ARRANGE_TRANSPORT') return null
return target.batchKeys.length && target.batchKeys.every(key => /^FBA_APPOINTMENT:\d+$/.test(key)) ? target.batchKeys : null
})
const existingDispatches = computed(() => {
const target = action.value?.target
if (target?.kind !== 'FLEET_TRIP' || target.tripId || action.value?.code !== 'ARRANGE_TRANSPORT' || target.batchKeys.length !== 1) return []
const batchKey = target.batchKeys[0]!, item = detail.value?.item
return /^DISPATCH:\d+$/.test(batchKey) && item?.batchKey === batchKey && item.batchNo ? [{ batchKey, dispatchNo: item.batchNo }] : []
})
const canAct = computed(() => !!detail.value && !!action.value && actionAllowed(detail.value.item, action.value) && !error.value && !loading.value)
const secondaryActions = computed(() => detail.value?.item.allowedActions.filter(candidate => candidate.code !== action.value?.code && (action.value || !detail.value?.item.nextAction || !sameAction(candidate, detail.value.item.nextAction))) || [])
async function choose(next: WorkAction) {
if (!detail.value || loading.value || error.value || !await props.canLeave()) return
actionNotice.value = ''
// 跨事项的主要下一步只用于导航,目标面板会重新读取并校验自己的写入资格。
const linkedNext = next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey && detail.value.item.nextAction && sameAction(next, detail.value.item.nextAction)
if (!linkedNext && !actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
saveCompleted.value = false
if (next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey) { emit('open', next.targetWorkItemKey, next); return }
const path = actionRoute(next, detail.value.references, detail.value.item)
if (path) { emit('external', path); return }
action.value = next; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; continuation.value = null; creatingFromCargo.value = false
if (next.code === 'EDIT_BATCH' && next.target.kind === 'FBA_APPOINTMENT' && next.target.batchKey) { batchKey.value = next.target.batchKey; return }
if (next.target.kind === 'WORK_COMMAND' && !commandName(next)) actionNotice.value = '当前操作尚未提供可用入口,请联系负责人核对。'
if (next.target.kind === 'FBA_APPOINTMENT' && !fbaNumber.value && !createSources.value) actionNotice.value = '尚未关联可操作的预约记录,请先核实批次与预约关系。'
if (next.target.kind === 'FLEET_TRIP' && !dispatchBatchKeys.value?.length && !existingDispatches.value.length) actionNotice.value = '尚未关联可进入的车次或可排批次,请核对运输来源。'
}
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
action.value = null; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; creatingFromCargo.value = false; actionNotice.value = ''; saveCompleted.value = true
emit('saved', result, command)
await load()
}
async function savedAppointment(appointment: FbaAppointmentDto) {
if (!appointment.batchKey) { await saved(); return }
action.value = null; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; saveCompleted.value = true
continuation.value = { batchKey: appointment.batchKey, labels: [...new Set(appointment.items.map(item => item.customerId))].map(customerId => ({ customerId, label: appointment.items.filter(item => item.customerId === customerId).map(item => item.inboundCode || '—').join('、') })) }
emit('updated')
}
async function receivingUpdated() { emit('updated'); await load() }
async function cargoSaved() { emit('updated'); await load() }
async function createFromCargo() { if (canAct.value && await props.canLeave()) creatingFromCargo.value = true }
async function openBatch(key: string) { if (await props.canLeave()) { batchKey.value = key; openedAppointmentNo.value = null; receivingKey.value = null } }
async function openAppointment(number: string) { if (await props.canLeave()) { openedAppointmentNo.value = number; batchKey.value = null; receivingKey.value = null } }
async function openReceiving(key: string) { if (await props.canLeave()) { receivingKey.value = key; batchKey.value = null; openedAppointmentNo.value = null } }
onMounted(async () => {
await load()
// A predecessor's action is only a hint: use the newly fetched item's own qualification.
if (props.initialAction && detail.value) {
const own = detail.value.item.allowedActions.find(candidate => candidate.code === props.initialAction?.code && candidate.target.kind === props.initialAction?.target.kind)
if (own) await choose(own)
else if (detail.value.item.nextAction) await choose(detail.value.item.nextAction)
}
})
onBeforeUnmount(() => { ++request; controller?.abort() })
</script>
<template>
<div class="work-detail" :aria-busy="loading">
<LoadErrorState v-if="error" title="工作事项读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading" role="status">{{ syncing ? '预约工作清单同步中,正在自动读取最新事项;已保存内容无需重复提交。' : '正在读取当前事项与操作资格…' }}</p>
<template v-if="detail">
<header class="detail-overview">
<div class="detail-title"><h3>{{ detail.item.title }}</h3><el-tag :type="detail.item.executionStatus === 'COMPLETED' ? 'success' : detail.item.reviewRequired ? 'warning' : 'info'" size="small">{{ workStatus(detail.item) }}</el-tag></div>
<p class="detail-category">{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }}</p>
</header>
<dl class="density-fields detail-facts" aria-label="事项关键信息">
<div><dt>作业时间</dt><dd>{{ workDate(detail.item) }}</dd></div>
<div><dt>作业时区</dt><dd>{{ detail.item.timezone || '地点时区待核实' }}</dd></div>
<div><dt>作业地点</dt><dd>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }}</dd></div>
<div><dt>负责人</dt><dd>{{ detail.item.owner?.name || '待分派' }}</dd></div>
<div class="followup-summary"><dt>下次跟进</dt><dd>{{ detail.item.followupDate || '尚未安排' }}<span v-if="detail.item.dateBasis === 'NONE'" class="field-note">作业日期未定,跟进日不作为作业日期</span></dd></div>
</dl>
<div v-if="detail.item.containers.length" class="container-links"><span>关联货柜</span><el-button v-for="container in detail.item.containers" :key="container.containerBookingId" class="density-action" data-tone="primary" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</el-button></div>
<aside v-if="needsFollowup || attentionReasons.length" class="attention-panel" aria-label="事项提醒">
<h4>事项提醒</h4>
<ul>
<li v-if="needsFollowup">{{ !detail.item.owner ? '负责人待分派。' : '' }}{{ !detail.item.followupDate ? '请补充下一次跟进安排。' : '' }}请记录跟进;若无操作权限,请联系负责人。</li>
<li v-for="reason in attentionReasons" :key="reason">{{ reason }}</li>
</ul>
</aside>
<p v-if="detail.returnHandoff">还空责任:{{ detail.returnHandoff.from.name }} → {{ detail.returnHandoff.to.name }} · {{ detail.returnHandoff.status === 'PENDING_ACCEPTANCE' ? '接手方待确认,持续跟进' : '已确认接手' }}</p>
<section class="current-work" aria-label="当前处理">
<h4>{{ detail.item.executionStatus === 'COMPLETED' ? '事项结果' : '当前处理' }}</h4>
<p v-if="detail.item.executionStatus === 'COMPLETED'" class="completion-summary">当前事项已完成,可查看关联工作和历史记录。</p>
<template v-if="!action && !continuation">
<p v-if="saveCompleted" role="status" class="save-result">本次保存已完成。{{ loading ? '正在读取当前下一步…' : error ? '下一步读取失败,请重试读取事项,无需重复提交已保存内容。' : detail.item.executionStatus === 'COMPLETED' ? '当前事项已完成,可返回查看其他待办。' : '请按当前下一步继续处理。' }}</p>
<el-button v-if="detail.item.nextAction" class="primary-next-action" type="primary" :disabled="loading || !!error" @click="choose(detail.item.nextAction)">{{ detail.item.nextAction.label }}</el-button>
<p v-else-if="detail.item.executionStatus !== 'COMPLETED'">当前没有可执行的下一步,请根据缺项或前置说明联系负责人。</p>
</template>
<p v-if="actionNotice" role="status" class="work-warning">{{ actionNotice }}</p>
<WorkBatchContinuation v-if="continuation" :key="JSON.stringify(continuation)" v-bind="continuation" @open="key => emit('open', key)" />
<WorkReceivingPanel v-else-if="receivingKey" :key="receivingKey" :batch-key="receivingKey" :owners="receivingOwners" :can-write="!loading && !error" @updated="receivingUpdated" @appointment="openAppointment" @batch="openBatch" @external="path => emit('external', path)" />
<AppointmentBatchForm v-else-if="batchKey" :key="batchKey" :batch-key="batchKey" @updated="emit('updated')" @appointment="openAppointment" />
<WorkAppointmentForm v-else-if="openedAppointmentNo" :key="openedAppointmentNo" :appointment-no="openedAppointmentNo" :cancel="false" :can-write="true" @saved="savedAppointment" @batch="openBatch" @receiving="openReceiving" />
<WorkCommandForm v-else-if="action && commandName(action)" :key="`${itemKey}:${action.code}`" :detail="detail" :action="action" :refreshing="loading || !!error" @refresh="load" @saved="saved" />
<template v-else-if="cargoSource && !creatingFromCargo"><CargoVerificationForm :key="`${cargoSource.customerId}:${cargoSource.sourceKey}:${cargoSource.destinationKey}`" :source="cargoSource" @saved="cargoSaved" /><el-button :disabled="!canAct" @click="createFromCargo">核对货物并新建原预约</el-button></template>
<WorkAppointmentForm v-else-if="fbaNumber && action" :key="`${fbaNumber}:${action.code}`" :appointment-no="fbaNumber" :cancel="action.code === 'CANCEL_BOOKING'" :can-write="canAct" @saved="savedAppointment" @batch="openBatch" @receiving="openReceiving" />
<WorkCreateAppointmentForm v-else-if="createSources" :key="JSON.stringify(createSources)" :sources="createSources" :can-write="canAct" @saved="saved()" />
<WorkDispatchForm v-else-if="dispatchBatchKeys?.length" :key="JSON.stringify(dispatchBatchKeys)" :batch-keys="dispatchBatchKeys" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" @batch="openBatch" />
<WorkExistingDispatchForm v-else-if="existingDispatches.length" :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
<WorkReceiptPlans v-else-if="action?.target.kind === 'WAREHOUSE_RECEIPT'" :key="action.target.containerBookingId" :container-booking-id="action.target.containerBookingId" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
<section v-else-if="action?.target.kind === 'READ_ONLY' && action.target.references.length" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
<details v-if="secondaryActions.length"><summary>更多操作</summary><div class="more-actions"><el-button v-for="candidate in secondaryActions" :key="`${candidate.code}:${candidate.targetWorkItemKey}`" :disabled="loading || !!error" @click="choose(candidate)">{{ candidate.label }}</el-button></div></details>
</section>
<section class="detail-records" aria-label="关联资料与历史">
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button class="density-action" @click="emit('open', related.workItemKey)">查看</el-button></div><p v-if="!detail.relatedItems.length">暂无关联工作</p></details>
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p><p v-if="!detail.references.length">暂无原业务记录</p></details>
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录 · {{ detail.history.length }} 条</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><div class="history-heading"><b>{{ historyLabels[entry.action] || '事项记录' }}</b><time :datetime="entry.occurredAt">{{ historyTime(entry.occurredAt) }}</time></div><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }}<span v-if="!historyLabels[entry.action]"> · {{ entry.action }}</span></small></div><p v-if="!detail.history.length">暂无记录</p></details>
</section>
</template>
</div>
</template>
<style scoped>
.work-detail { font-size: var(--admin-font-body); line-height: 1.6; color: var(--el-text-color-primary); }
.detail-overview { margin-bottom: var(--admin-space-5); }
.detail-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.detail-title h3 { margin: 0; font-size: var(--admin-font-title); }
.detail-category { margin: var(--admin-space-1) 0 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.work-detail .detail-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.followup-summary { grid-column: 1 / -1; }
.field-note { margin-left: var(--admin-space-5); color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.container-links, .more-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.container-links { margin-top: var(--admin-space-3); }
.container-links > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.attention-panel { margin-top: var(--admin-space-5); padding: var(--admin-space-3) var(--admin-space-5); border-left: 3px solid var(--el-color-warning); background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); }
.attention-panel h4, .current-work > h4 { margin: 0 0 var(--admin-space-1); font-size: var(--admin-font-section); }
.attention-panel ul { margin: 0; padding-left: var(--admin-space-6); font-size: var(--admin-font-caption); }
.current-work { margin-top: var(--admin-space-5); padding-block: var(--admin-space-5); border-top: 1px solid var(--cc-border); }
.current-work > p { margin: var(--admin-space-2) 0; }
.completion-summary { color: var(--el-color-success-dark-2); }
.detail-records { border-top: 1px solid var(--cc-border); }
.detail-records > details { border-bottom: 1px solid var(--cc-border); }
summary { cursor: pointer; padding-block: var(--admin-space-3); font-weight: 600; }
summary:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -2px; }
.detail-records p { margin: var(--admin-space-1) 0; }
.related-row, .history-heading { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); }
.related-row, .history-entry { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }
.related-row:last-child, .history-entry:last-child { border-bottom: 0; }
.related-row p, .history-entry small, .history-heading time { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.work-warning { color: var(--el-color-warning-dark-2); }
</style>