Files
admin-web/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue
T

156 lines
14 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/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
import { createPortSchedule, getPortSchedule, updatePortSchedule } from '../api/platformPortSchedules'
import type { PortScheduleData, PortScheduleDetail, PortScheduleSaveRequest } from '../types'
const props = defineProps<{ container: {
id: string; currentPortScheduleId?: string | null; customerId?: string | null; customerName?: string | null
oceanBookingId?: string | null; billOfLadingNo?: string | null; cabinetNo: string; containerNo: string | null
vesselName: string | null; voyageNo: string | null; departurePortCode: string | null; departurePortName?: string | null
destinationPortCode: string | null; destinationPortName?: string | null
estimatedSailingAt: string | null; estimatedPortArrivalAt: string | null
} }>()
const dialogTitle = computed(() => {
const action = props.container.currentPortScheduleId ? '原柜船期与修改历史' : '登记原柜船期'
const code = props.container.cabinetNo?.trim() || props.container.containerNo?.trim()
return code ? `${action} · ${code}` : action
})
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const canRead = computed(() => access.can('platform:firstleg:port-schedule:list'))
const canWrite = computed(() => access.can(`platform:firstleg:port-schedule:${props.container.currentPortScheduleId || schedule.value ? 'edit' : 'create'}`))
const open = ref(false), busy = ref(false), loading = ref(false), error = ref('')
const schedule = ref<PortScheduleDetail | null>(null)
const pending = ref<{ id: string | null; key: string; request: PortScheduleSaveRequest } | null>(null)
const form = reactive({ vesselName: '', voyageNo: '', etdAt: '', etaAt: '', reason: '', sourceName: '', sourceRef: '', observedAt: '', expiresAt: '', portCutoffAt: '', bookingCutoffAt: '', customsCutoffAt: '' })
let generation = 0
const scope = computed(() => JSON.stringify([props.container.id, props.container.customerId, props.container.oceanBookingId, props.container.currentPortScheduleId]))
const prerequisite = computed(() => {
const c = props.container
if (!c.oceanBookingId || c.customerId == null || c.customerId === '') return '尚未取得原订舱及客户归属,不能维护船期。'
if (!c.currentPortScheduleId && (!c.departurePortCode || !c.destinationPortCode)) return '原订舱起运港或目的港缺失,不能登记船期。'
return ''
})
const dates = ['etdAt', 'etaAt'] as const
const extraDates = ['observedAt', 'expiresAt', 'portCutoffAt', 'bookingCutoffAt', 'customsCutoffAt'] as const
const dateLabels = { etdAt: '预计开船 ETD', etaAt: '预计到港 ETA', observedAt: '信息确认时间', expiresAt: '信息有效至', portCutoffAt: '截港时间', bookingCutoffAt: '截单时间(选填)', customsCutoffAt: '截关时间(选填)' }
function time(value?: string | null) { return value ? value.replace('T', ' ') : '—' }
function snapshot(raw?: string | null) {
if (!raw) return '—'
try { const value = JSON.parse(raw) as PortScheduleData; return `${value.vesselName || '—'} / ${value.voyageNo || '—'};ETD ${time(value.etdAt)};ETA ${time(value.etaAt)}` }
catch { return '历史快照无法读取' }
}
async function begin() {
if (!canRead.value || prerequisite.value) return
open.value = true; loading.value = true; error.value = ''; schedule.value = null; pending.value = null
const current = ++generation, c = props.container
Object.assign(form, { vesselName: c.vesselName || '', voyageNo: c.voyageNo || '', etdAt: c.estimatedSailingAt || '', etaAt: c.estimatedPortArrivalAt || '', reason: '', sourceName: '', sourceRef: '', observedAt: '', expiresAt: '', portCutoffAt: '', bookingCutoffAt: '', customsCutoffAt: '' })
try {
if (c.currentPortScheduleId) {
const result = await getPortSchedule(c.currentPortScheduleId)
if (current !== generation || !open.value) return
const value = result.portSchedule.schedule
if (value.id !== c.currentPortScheduleId || value.containerBookingId !== c.id || value.oceanBookingId !== c.oceanBookingId || value.customerId !== c.customerId) throw new Error('当前船期不属于该柜的原客户和订舱,不能修改共享或其他柜船期。')
schedule.value = result
Object.assign(form, { vesselName: value.vesselName, voyageNo: value.voyageNo, etdAt: value.etdAt || '', etaAt: value.etaAt || '' })
}
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '船期读取失败').message }
finally { if (current === generation) loading.value = false }
}
async function save() {
if (!open.value || loading.value || busy.value || !canRead.value || !canWrite.value || prerequisite.value) return
if (props.container.currentPortScheduleId && !schedule.value) return
if (!pending.value) {
const original = schedule.value?.portSchedule.schedule
if (original && form.vesselName.trim() === original.vesselName && form.voyageNo.trim() === original.voyageNo
&& form.etdAt === (original.etdAt || '') && form.etaAt === (original.etaAt || '')) {
error.value = '船期信息未变更,无需重复保存。'; return
}
if (!form.vesselName.trim() || !form.voyageNo.trim() || !form.etdAt || !form.etaAt || !form.reason.trim()) { error.value = '请填写船名、航次、预计开船/到港时间和修改原因。'; return }
if (form.etaAt < form.etdAt) { error.value = '预计到港时间不能早于预计开船时间。'; return }
let data: PortScheduleData
if (schedule.value) {
const { id: _id, scheduleNo: _no, version: _version, updatedAt: _updatedAt, ...original } = schedule.value.portSchedule.schedule
data = { ...original, vesselName: form.vesselName.trim(), voyageNo: form.voyageNo.trim(), etdAt: form.etdAt, etaAt: form.etaAt }
} else {
if (!form.sourceName.trim() || !form.sourceRef.trim() || !form.observedAt || !form.expiresAt || !form.portCutoffAt) { error.value = '首次登记须填写来源名、来源参考、信息确认时间、信息有效至和截港时间。'; return }
if (form.expiresAt <= form.observedAt || form.portCutoffAt > form.etdAt || (form.bookingCutoffAt && form.bookingCutoffAt > form.etdAt) || (form.customsCutoffAt && form.customsCutoffAt > form.etdAt)) { error.value = '有效至须晚于确认时间,各截点不得晚于预计开船。'; return }
const c = props.container
data = { customerId: c.customerId!, oceanBookingId: c.oceanBookingId!, containerBookingId: c.id, sourceType: 'MANUAL', sourceName: form.sourceName.trim(), sourceRef: form.sourceRef.trim(), vesselName: form.vesselName.trim(), voyageNo: form.voyageNo.trim(), departurePortCode: c.departurePortCode!, departurePortName: c.departurePortName, destinationPortCode: c.destinationPortCode!, destinationPortName: c.destinationPortName, etdAt: form.etdAt, etaAt: form.etaAt, observedAt: form.observedAt, expiresAt: form.expiresAt, portCutoffAt: form.portCutoffAt, bookingCutoffAt: form.bookingCutoffAt || null, customsCutoffAt: form.customsCutoffAt || null }
}
pending.value = { id: schedule.value?.portSchedule.schedule.id || null, key: crypto.randomUUID(), request: { expectedVersion: schedule.value?.portSchedule.schedule.version ?? 0, data, reason: form.reason.trim() } }
}
const current = generation, command = pending.value
busy.value = true; error.value = ''
try {
const result = command.id ? await updatePortSchedule(command.id, command.request, command.key) : await createPortSchedule(command.request.data, command.key, command.request.reason || '')
if (current !== generation || !open.value) return
schedule.value = result; pending.value = null; emit('saved')
} catch (cause) {
if (current === generation) {
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '船期保存失败').message
if (cause instanceof ApiResponseError && cause.code === 'A0409') error.value += ';输入已保留,请关闭后重新打开核对最新版本。'
}
} finally { pending.value = null; if (current === generation) busy.value = false }
}
watch(open, value => { if (!value) { generation++; loading.value = false; busy.value = false } })
watch(scope, () => { open.value = false; generation++; schedule.value = null; pending.value = null })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<div class="schedule-actions admin-workbench">
<el-button v-if="canRead" :disabled="!!prerequisite" @click="begin">船期维护与历史</el-button>
<span v-if="prerequisite && container.oceanBookingId && container.customerId">{{ prerequisite }}</span>
<span v-if="!prerequisite && !canRead">当前账号无船期读取权限。</span>
</div>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="dialogTitle" fullscreenable :width="container.currentPortScheduleId ? '1000px' : '1120px'" :show-confirm="canWrite" :confirm-disabled="loading || !!prerequisite || (!!container.currentPortScheduleId && !schedule)" :confirm-loading="busy" confirm-text="保存船期" @confirm="save">
<div v-loading="loading" class="density-dialog-sections">
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<section class="density-dialog-section" data-tone="source">
<div class="workbench-evidence"><table class="density-table schedule-context-table" aria-label="原柜船期归属"><tbody>
<tr><th>柜次号</th><td>{{ container.cabinetNo || '—' }}</td><th>集装箱号</th><td>{{ container.containerNo || '—' }}</td></tr>
<tr><th>提单号</th><td>{{ container.billOfLadingNo || '—' }}</td><th>客户</th><td>{{ container.customerName || '—' }}</td></tr>
</tbody></table></div>
</section>
<section class="density-dialog-section">
<el-form v-if="canWrite" class="density-form" :disabled="busy || !!pending || loading">
<div class="workbench-evidence"><table class="density-table schedule-editor-table" aria-label="原柜船期填写"><tbody>
<tr><td><el-form-item label="船名" label-position="left" label-width="120px" required><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></td><td><el-form-item label="航次" label-position="left" label-width="120px" required><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
<tr><td v-for="key in dates" :key="key"><el-form-item :label="dateLabels[key]" label-position="left" label-width="120px" required><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></td></tr>
<template v-if="!container.currentPortScheduleId">
<tr><td><el-form-item label="信息来源" label-position="left" label-width="120px" required><el-input v-model="form.sourceName" maxlength="128" placeholder="请输入信息来源" /></el-form-item></td><td><el-form-item label="来源参考号或链接" label-position="left" label-width="120px" required><el-input v-model="form.sourceRef" maxlength="255" placeholder="请输入来源参考号或链接" /></el-form-item></td></tr>
<tr v-for="(keys, index) in [extraDates.slice(0, 2), extraDates.slice(2, 4), extraDates.slice(4)]" :key="index"><td v-for="key in keys" :key="key" :colspan="keys.length === 1 ? 2 : 1"><el-form-item :label="dateLabels[key]" label-position="left" label-width="120px" :required="!['bookingCutoffAt', 'customsCutoffAt'].includes(key)"><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></td></tr>
</template>
<tr><td colspan="2"><el-form-item label="修改原因" label-position="left" label-width="120px" required><el-input v-model="form.reason" type="textarea" maxlength="1000" placeholder="请说明本次修改原因" /></el-form-item></td></tr>
</tbody></table></div>
</el-form>
<div class="workbench-evidence" v-else><table class="density-table schedule-context-table" aria-label="当前船期只读资料"><tbody>
<tr><th>船名</th><td>{{ form.vesselName || '—' }}</td><th>航次</th><td>{{ form.voyageNo || '—' }}</td></tr>
<tr><template v-for="key in dates" :key="key"><th>{{ dateLabels[key] }}</th><td>{{ time(form[key]) }}</td></template></tr>
</tbody></table></div>
</section>
<section class="density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">修改历史</h3>
<el-table :data="schedule?.histories || []" empty-text="暂无修改历史">
<el-table-column label="修改时间" min-width="170"><template #default="{ row }">{{ time(row.createdAt) }}</template></el-table-column>
<el-table-column prop="operatorName" label="修改人" width="125" show-overflow-tooltip />
<el-table-column prop="reason" label="原因" min-width="150" show-overflow-tooltip />
<el-table-column label="修改前" min-width="240"><template #default="{ row }">{{ snapshot(row.beforeSnapshot) }}</template></el-table-column>
<el-table-column label="修改后" min-width="240"><template #default="{ row }">{{ snapshot(row.afterSnapshot) }}</template></el-table-column>
</el-table>
</section>
</div>
</AppDialog>
</template>
<style scoped>
.schedule-actions { display:flex; align-items:center; gap:var(--admin-space-5); margin:var(--admin-space-3) 0; }
.schedule-editor-table, .schedule-context-table { table-layout:fixed; }
.schedule-context-table th { width:120px; }
</style>