78 lines
15 KiB
Vue
78 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||
import DataTable from '@/shared/components/DataTable.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||
import { createPortSchedule, getPortSchedule, listPortSchedules, updatePortSchedule } from '../api/platformPortSchedules'
|
||
import type { PortScheduleData, PortScheduleView } from '../types'
|
||
|
||
const access = useAccessPreviewStore()
|
||
const rows = ref<PortScheduleView[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||
const query = reactive<QueryModel>({ keyword: '', expired: '' })
|
||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '关键词', placeholder: '船期号/船名/航次/港口', width: 230 }, { key: 'expired', type: 'select', label: '有效性', options: [{ label: '有效', value: 'false' }, { label: '已过期', value: 'true' }] }]
|
||
const columns = [
|
||
{ key: 'actions', label: '操作', fixed: 'left', width: 165, slotName: 'actions', hideable: false, movable: false },
|
||
{ prop: 'schedule.scheduleNo', label: '船期编号', minWidth: 145 }, { prop: 'schedule.sourceName', label: '信息来源', minWidth: 130 },
|
||
{ prop: 'schedule.vesselName', label: '船名/航次', minWidth: 155, slotName: 'vessel' }, { prop: 'schedule.departurePortName', label: '起运港', minWidth: 120 },
|
||
{ prop: 'schedule.destinationPortName', label: '目的港', minWidth: 120 }, { prop: 'schedule.etdAt', label: '预计开船', minWidth: 160 },
|
||
{ prop: 'schedule.etaAt', label: '预计到港', minWidth: 160 }, { prop: 'schedule.observedAt', label: '查询时间', minWidth: 160, slotName: 'observedAt' }, { prop: 'schedule.updatedAt', label: '更新时间', minWidth: 160, slotName: 'updatedAt' }, { prop: 'expired', label: '有效性', width: 95, slotName: 'expired' },
|
||
]
|
||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||
function blank(): PortScheduleData { const now = new Date().toISOString().slice(0, 19); return { customerId: '', oceanBookingId: '', containerBookingId: null, sourceType: 'MANUAL', sourceName: '', sourceRef: '', vesselName: '', voyageNo: '', departurePortCode: '', departurePortName: '', destinationPortCode: '', destinationPortName: '', etdAt: null, etaAt: null, bookingCutoffAt: null, customsCutoffAt: null, portCutoffAt: null, observedAt: now, expiresAt: now, remark: '' } }
|
||
type DialogMode = 'create' | 'view' | 'edit'
|
||
const dialogOpen = ref(false); const dialogMode = ref<DialogMode>('create'); const saving = ref(false); const editingId = ref(''); const expectedVersion = ref(0); const formRef = ref<FormInstance>(); const form = reactive<PortScheduleData>(blank()); const actionError = ref<ApiErrorDetails | null>(null)
|
||
const rules: FormRules = { customerId: [{ required: true, message: '请选择客户', trigger: 'change' }], oceanBookingId: [{ required: true, message: '请选择订舱记录', trigger: 'change' }], sourceName: [{ required: true, message: '请输入信息来源', trigger: 'blur' }], sourceRef: [{ required: true, message: '请输入来源链接或参考号', trigger: 'blur' }], vesselName: [{ required: true, message: '请输入船名', trigger: 'blur' }], voyageNo: [{ required: true, message: '请输入航次', trigger: 'blur' }], departurePortCode: [{ required: true, message: '请输入起运港代码', trigger: 'blur' }], destinationPortCode: [{ required: true, message: '请输入目的港代码', trigger: 'blur' }], observedAt: [{ required: true, message: '请选择查询时间', trigger: 'change' }], expiresAt: [{ required: true, message: '请选择过期时间', trigger: 'change' }] }
|
||
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref<ApiErrorDetails | null>(null); let customerRequestSerial = 0
|
||
const bookings = ref<OceanBookingResource[]>([]); const bookingsLoading = ref(false); const bookingsError = ref<ApiErrorDetails | null>(null); let bookingRequestSerial = 0
|
||
const selectableCustomers = computed(() => customers.value.filter((customer) => (
|
||
customer.status === 'ACTIVE' || String(customer.id) === String(form.customerId)
|
||
)))
|
||
const canCreate = computed(() => access.can('platform:firstleg:port-schedule:create'))
|
||
const dialogTitle = computed(() => dialogMode.value === 'create' ? '新增船期' : dialogMode.value === 'edit' ? '编辑船期' : '查看船期')
|
||
const dialogReadonly = computed(() => dialogMode.value === 'view')
|
||
function customerLabel(customer: PlatformTenantResource) { return `${customer.tenantName}(${customer.tenantCode})` }
|
||
function bookingLabel(booking: OceanBookingResource) { return [booking.bookingNo || '待生成订舱号', [booking.vesselName, booking.voyageNo].filter(Boolean).join(' / '), [booking.departurePortCode, booking.destinationPortCode].filter(Boolean).join(' → ')].filter(Boolean).join(' · ') }
|
||
async function loadCustomers() { const serial = ++customerRequestSerial; customersLoading.value = true; customersError.value = null; try { const result = await listPlatformTenants({ page: 1, size: 200 }); if (serial === customerRequestSerial) customers.value = result.records } catch (cause) { if (serial === customerRequestSerial) customersError.value = getApiErrorDetails(cause, '客户候选加载失败') } finally { if (serial === customerRequestSerial) customersLoading.value = false } }
|
||
async function loadBookings(customerId = String(form.customerId || '')) { const serial = ++bookingRequestSerial; bookings.value = []; bookingsError.value = null; if (!customerId) { bookingsLoading.value = false; return } bookingsLoading.value = true; try { const result = await listOceanBookings({ page: 1, size: 200, customerId }); if (serial === bookingRequestSerial && customerId === String(form.customerId || '')) bookings.value = result.records } catch (cause) { if (serial === bookingRequestSerial && customerId === String(form.customerId || '')) bookingsError.value = getApiErrorDetails(cause, '订舱候选加载失败') } finally { if (serial === bookingRequestSerial) bookingsLoading.value = false } }
|
||
function handleCustomerChange(value: string) { form.customerId = String(value || ''); form.oceanBookingId = ''; void formRef.value?.clearValidate('oceanBookingId'); void loadBookings(form.customerId) }
|
||
async function load() { loading.value = true; error.value = null; try { const result = await listPortSchedules({ page: page.value, size: pageSize.value, keyword: String(query.keyword || '') || undefined, expired: query.expired === '' ? undefined : query.expired === 'true' }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
|
||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||
function openCreate() { dialogMode.value = 'create'; editingId.value = ''; expectedVersion.value = 0; Object.assign(form, blank()); bookings.value = []; bookingsError.value = null; actionError.value = null; dialogOpen.value = true; if (!customers.value.length) void loadCustomers() }
|
||
async function openSchedule(id: string, mode: Extract<DialogMode, 'view' | 'edit'>) { actionError.value = null; try { const result = await getPortSchedule(id); dialogMode.value = mode; editingId.value = id; expectedVersion.value = result.portSchedule.schedule.version; Object.assign(form, result.portSchedule.schedule); dialogOpen.value = true; if (!customers.value.length) void loadCustomers(); void loadBookings(String(form.customerId || '')) } catch (cause) { error.value = getApiErrorDetails(cause) } }
|
||
async function save() { if (dialogReadonly.value || !await formRef.value?.validate().catch(() => false)) return; saving.value = true; actionError.value = null; try { if (editingId.value) await updatePortSchedule(editingId.value, { expectedVersion: expectedVersion.value, data: { ...form }, reason: '后台维护船期' }); else await createPortSchedule({ ...form }); ElMessage.success(editingId.value ? '船期已更新' : '船期已新增'); dialogOpen.value = false; await load() } catch (cause) { actionError.value = getApiErrorDetails(cause); if (actionError.value.code === 'A0409' && editingId.value) await openSchedule(editingId.value, 'edit') } finally { saving.value = false } }
|
||
watch([page, pageSize], () => void load()); onMounted(load)
|
||
</script>
|
||
<template>
|
||
<AdminPage as="div"><ListPageLayout aria-label="船期列表">
|
||
<template #toolbar><QueryToolbar v-model="query" class="surface-panel" ariaLabel="船期筛选" :fields="fields" @search="search" /></template>
|
||
<template v-if="error" #state><LoadErrorState title="船期列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe row-key="schedule.id" preference-key="port-schedules" empty-text="暂无符合条件的船期">
|
||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增船期</el-button></template>
|
||
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:port-schedule:view')" size="small" type="primary" plain @click="openSchedule(row.schedule.id, 'view')"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:port-schedule:edit')" size="small" @click="openSchedule(row.schedule.id, 'edit')"><Pencil :size="15" />编辑</el-button></div></template>
|
||
<template #vessel="{ row }">{{ row.schedule.vesselName }} / {{ row.schedule.voyageNo }}</template><template #observedAt="{ row }">{{ formatDateTime(row.schedule.observedAt) }}</template><template #updatedAt="{ row }">{{ formatDateTime(row.schedule.updatedAt) }}</template><template #expired="{ row }"><el-tag :type="row.expired ? 'danger' : 'success'">{{ row.expired ? '已过期' : '有效' }}</el-tag></template>
|
||
</DataTable></section>
|
||
</ListPageLayout>
|
||
<AppDialog v-model="dialogOpen" :title="dialogTitle" width="820px" :confirm-loading="saving" :show-confirm="!dialogReadonly" :cancel-text="dialogReadonly ? '关闭' : '取消'" @confirm="save"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><div v-if="customersError" class="selector-error"><span>{{ customersError.message }}</span><el-button link type="primary" @click="loadCustomers">重新加载客户</el-button></div><div v-if="bookingsError" class="selector-error"><span>{{ bookingsError.message }}</span><el-button link type="primary" @click="loadBookings()">重新加载订舱</el-button></div><el-form ref="formRef" :model="form" :rules="rules" :disabled="dialogReadonly" label-position="top" class="schedule-form"><el-row :gutter="16">
|
||
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="form.customerId" filterable placeholder="请选择客户" :loading="customersLoading" no-data-text="暂无可用客户" style="width:100%" @update:model-value="handleCustomerChange(String($event || ''))"><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customerLabel(customer)" :value="String(customer.id)" /></el-select></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="订舱记录" prop="oceanBookingId"><el-select v-model="form.oceanBookingId" filterable placeholder="请先选择客户,再选择订舱记录" :loading="bookingsLoading" :disabled="!form.customerId" no-data-text="该客户暂无订舱记录" style="width:100%"><el-option v-for="booking in bookings" :key="booking.id" :label="bookingLabel(booking)" :value="booking.id" /></el-select></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="信息来源" prop="sourceName"><el-input v-model="form.sourceName" placeholder="如美森官网" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="来源参考" prop="sourceRef"><el-input v-model="form.sourceRef" placeholder="查询链接或参考号" /></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="船名" prop="vesselName"><el-input v-model="form.vesselName" placeholder="请输入船名" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="航次" prop="voyageNo"><el-input v-model="form.voyageNo" placeholder="请输入航次" /></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="起运港代码" prop="departurePortCode"><el-input v-model="form.departurePortCode" placeholder="例如 CNSHA" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="目的港代码" prop="destinationPortCode"><el-input v-model="form.destinationPortCode" placeholder="例如 USLAX" /></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="预计开船"><el-date-picker v-model="form.etdAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="预计到港"><el-date-picker v-model="form.etaAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item></el-col>
|
||
<el-col :xs="24" :sm="12"><el-form-item label="查询时间" prop="observedAt"><el-date-picker v-model="form.observedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择本次查询时间" style="width:100%" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="过期时间" prop="expiresAt"><el-date-picker v-model="form.expiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择结果过期时间" style="width:100%" /></el-form-item></el-col>
|
||
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入船期补充说明(可选)" /></el-form-item></el-col>
|
||
</el-row></el-form></AppDialog></AdminPage>
|
||
</template>
|
||
<style scoped>.schedule-form{max-height:65vh;overflow:auto;padding-inline:2px}.selector-error{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;padding:6px 10px;border-radius:var(--cc-radius-sm);background:var(--cc-danger-soft);color:var(--el-color-danger);font-size:12px;line-height:20px}.selector-error span{overflow-wrap:anywhere}:global(.el-dialog:has(.schedule-form)){width:min(820px,calc(100vw - 28px))!important;max-height:90vh;overflow:hidden}@media(max-width:720px){.schedule-form{max-height:68vh}}</style>
|