Files
admin-web/src/modules/appointment-calendar/AppointmentCreatePanel.vue
T
2026-10-21 14:30:33 +08:00

162 lines
13 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 '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
import { listPlanningCargo, listPlanningDestinations, resolvePlanningSource, type PlanningCargo } from './planningApi'
import { useWorkDraft, type LeaveGuard } from './useWorkDraft'
import type { SourceReference } from './workApi'
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
const props = defineProps<{ canWrite: boolean; canLeave: LeaveGuard }>()
const emit = defineEmits<{ created: [appointment: FbaAppointmentDto] }>()
const query = ref<QueryModel>({ keyword: '', destination: '' }), keyword = ref(''), destination = ref(''), page = ref(1), total = ref(0)
const destinations = ref<string[]>([]), destinationsLoading = ref(false), destinationsLoaded = ref(false), destinationError = ref<ApiErrorDetails | null>(null)
let destinationsRequest = 0, destinationsController: AbortController | null = null
const fields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 },
{ key: 'destination', type: 'select', label: '目的FBA仓', placeholder: '搜索或选择目的FBA仓', width: 220,
options: destinations.value.map(code => ({ label: code, value: code })),
props: { filterable: true, loading: destinationsLoading.value, onVisibleChange: (visible: boolean) => {
if (visible && !destinationsLoaded.value) void loadDestinations()
} } },
])
const rows = ref<PlanningCargo[]>([]), selected = ref<Record<string, PlanningCargo>>({})
const selectedRows = computed(() => Object.values(selected.value))
const loading = ref(false), resolving = ref(false), error = ref<ApiErrorDetails | null>(null), selectionError = ref<ApiErrorDetails | null>(null)
const references = ref<SourceReference[] | null>(null), created = ref<FbaAppointmentDto | null>(null)
const createdForm = computed(() => createAppointmentForm(created.value || undefined))
useWorkDraft(computed(() => !references.value && !created.value && selectedRows.value.length > 0), computed(() => false))
let generation = 0, selectionGeneration = 0, controller: AbortController | null = null
const columns: DataTableColumn[] = [
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
{ prop: 'customerName', label: '客户', minWidth: 140, slotName: 'customer' },
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '箱数', width: 80 },
{ key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility' },
]
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
function reason(row: PlanningCargo) {
if (row.detained) return '货物暂扣中'
if (row.mixedDestinations) return '多个目的地,需先核对'
if (row.hasActiveAppointment) return '已有预约安排,请到入库单编辑原预约'
const anchor = selectedRows.value[0]
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
return ''
}
function toggle(row: PlanningCargo, checked: boolean | string | number) {
if (!props.canWrite || resolving.value) return
if (!checked) delete selected.value[key(row)]
else if (!reason(row)) selected.value[key(row)] = row
selectionError.value = null
}
async function load(nextPage = page.value) {
if (!props.canWrite) return
page.value = nextPage
const current = ++generation; controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null
try {
const result = await listPlanningCargo({ page: nextPage, size: 20, keyword: keyword.value || undefined,
...(destination.value ? { destination: destination.value } : {}) }, controller.signal)
if (current === generation) { rows.value = result.records; total.value = result.total }
} catch (cause) {
if (current === generation) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '可预约货物读取失败') }
} finally { if (current === generation) loading.value = false }
}
function search() {
keyword.value = String(query.value.keyword || '').trim()
destination.value = String(query.value.destination || '').trim()
void load(1)
}
async function loadDestinations() {
if (!props.canWrite || destinationsLoading.value) return
const current = ++destinationsRequest
destinationsController?.abort(); destinationsController = new AbortController()
destinationsLoading.value = true; destinationError.value = null
try {
const result = await listPlanningDestinations(destinationsController.signal)
if (current === destinationsRequest) { destinations.value = result; destinationsLoaded.value = true }
} catch (cause) {
if (current === destinationsRequest) destinationError.value = getApiErrorDetails(cause, '目的FBA仓选项读取失败')
} finally { if (current === destinationsRequest) destinationsLoading.value = false }
}
async function prepare() {
if (!props.canWrite || !selectedRows.value.length || loading.value || resolving.value || error.value) return
const current = ++selectionGeneration, snapshot = [...selectedRows.value]
resolving.value = true; selectionError.value = null
try {
const sources: SourceReference[] = []
// 仅对明确选中的货物读取最新来源,不按展示行逐票请求,也不猜目的地键。
for (const row of snapshot) {
if (current !== selectionGeneration) return
const { source } = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId })
if (current !== selectionGeneration) return
if (!source.canFollowup) { selectionError.value = { message: source.blockReasons.join(';') || '所选货物当前不能新增预约,请重新核对。' }; return }
sources.push({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })
}
if (current === selectionGeneration) references.value = sources
} catch (cause) { if (current === selectionGeneration) selectionError.value = getApiErrorDetails(cause, '所选货物核对失败') }
finally { if (current === selectionGeneration) resolving.value = false }
}
async function back() { if (await props.canLeave()) references.value = null }
function saved(appointment: FbaAppointmentDto) {
created.value = appointment; references.value = null; selected.value = {}
emit('created', appointment)
}
async function again() {
if (!await props.canLeave()) return
created.value = null; references.value = null; selected.value = {}; selectionError.value = null
await load()
}
onMounted(() => { void load() })
onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsRequest; controller?.abort(); destinationsController?.abort() })
</script>
<template>
<section class="density-dialog-sections" aria-label="新增FBA送仓预约">
<template v-if="created">
<section class="density-dialog-section" data-tone="summary" aria-label="预约保存回执">
<h3 class="density-dialog-section__title" role="status">预约已保存:{{ created.appointmentNo || '预约编号未返回' }}</h3>
<table class="density-table"><tbody>
<tr><th scope="row">系统单号</th><td>{{ created.appointmentNo || '—' }}</td><th scope="row">目的仓时区</th><td>{{ created.timezone || '待核实' }}</td></tr>
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
<tr><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
<tr><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
</tbody></table>
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
</section>
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
</template>
<template v-else-if="references">
<div class="density-actions"><el-button @click="back">返回选择货物</el-button></div>
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
</template>
<template v-else>
<section class="density-dialog-section" data-tone="source" aria-label="选择预约货物">
<div class="workbench-table"><DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" max-height="" empty-text="暂无可预约货物" @update:page="load($event)">
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
<template #toolbar-actions><InlineHelp title="选货说明"><p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p></InlineHelp><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
<template #eligibility="{ row }">{{ reason(row as PlanningCargo) || '可选择' }}</template>
<template #customer="{ row }"><LineClampText :content="row.customerName" /></template>
<template #origin="{ row }"><LineClampText :content="row.originWarehouseName" /></template>
</DataTable></div>
</section>
</template>
</section>
</template>