622 lines
66 KiB
Vue
622 lines
66 KiB
Vue
<script setup lang="ts">
|
||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||
import '@/styles/adminDensity.css'
|
||
import '@/styles/adminWorkbench.css'
|
||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { ChevronLeft, ChevronRight, Copy, Eye, Pencil, Plus, RotateCcw, XCircle } from '@lucide/vue'
|
||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import DataTable from '@/shared/components/DataTable.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import {
|
||
cancelSailing,
|
||
copySailing,
|
||
createSailingTemplate,
|
||
createSailing,
|
||
generateSailingTemplate,
|
||
getSailing,
|
||
getSailingBookingAdvice,
|
||
getSailingVolumeDetail,
|
||
listSailingChannels,
|
||
listSailingCalendar,
|
||
listSailingTemplates,
|
||
previewSailingTemplateGeneration,
|
||
updateSailingTemplate,
|
||
updateSailing,
|
||
} from '../api/platformSailingCalendar'
|
||
import type { SailingBookingAdvice, SailingCalendarEntry, SailingCalendarFilterOptions, SailingCalendarResponse, SailingChannelResource, SailingHistory, SailingSaveRequest, SailingStatus, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types'
|
||
|
||
interface CalendarCell { date: string; day: number; currentMonth: boolean; records: SailingCalendarEntry[] }
|
||
|
||
const access = useAccessPreviewStore()
|
||
let today = shanghaiToday()
|
||
let refreshTimer: ReturnType<typeof setTimeout> | undefined
|
||
let disposed = false
|
||
let calendarRequest = 0
|
||
let volumeRequest = 0
|
||
let historyRequest = 0
|
||
let adviceRequest = 0
|
||
const month = ref(formatMonth(today))
|
||
const selectedDate = ref(formatDate(today))
|
||
const data = ref<SailingCalendarResponse | null>(null)
|
||
const loading = ref(false)
|
||
const error = ref<ApiErrorDetails | null>(null)
|
||
const query = reactive({ warehouseId: '', channelCode: '', departurePortCode: '', destinationPortCode: '', keyword: '', status: '' as '' | SailingStatus })
|
||
const selectedSailing = ref<SailingCalendarEntry | null>(null)
|
||
const dialogOpen = ref(false)
|
||
const dialogMode = ref<'create' | 'edit'>('create')
|
||
const editingSailing = ref<Pick<SailingCalendarEntry, 'id' | 'sailingNo'> | null>(null)
|
||
const sailingEditorTitle = computed(() => {
|
||
if (dialogMode.value === 'create') return '新增船期'
|
||
const code = editingSailing.value?.sailingNo?.trim()
|
||
return code ? `编辑船期 · ${code}` : '编辑船期'
|
||
})
|
||
const saving = ref(false)
|
||
const detailOpen = ref(false)
|
||
const historyOpen = ref(false)
|
||
const historyLoading = ref(false)
|
||
const histories = ref<SailingHistory[]>([])
|
||
const detailLoading = ref(false)
|
||
const volumeDetail = ref<SailingVolumeDetail | null>(null)
|
||
const adviceOpen = ref(false)
|
||
const adviceLoading = ref(false)
|
||
const bookingAdvice = ref<SailingBookingAdvice | null>(null)
|
||
const cutoffMode = ref<'rule' | 'custom'>('rule')
|
||
const cutoffDays = ref(3)
|
||
const cutoffLocalTime = ref('18:00:00')
|
||
const form = reactive<SailingSaveRequest>(blankForm())
|
||
const managementOpen = ref(false)
|
||
const managementTab = ref<'channels' | 'templates'>('channels')
|
||
const managementLoading = ref(false)
|
||
const managementError = ref<ApiErrorDetails | null>(null)
|
||
const channels = ref<SailingChannelResource[]>([])
|
||
const warehouseOptions = ref<PlatformWarehouseSummaryResource[]>([])
|
||
const warehouseLoading = ref(false)
|
||
let warehouseRequest: Promise<void> | null = null
|
||
const warehousesLoaded = ref(false)
|
||
const formOptionsLoading = ref(false)
|
||
const routeExpanded = ref<string[]>([])
|
||
const templates = ref<SailingTemplateResource[]>([])
|
||
const templateDialogOpen = ref(false)
|
||
const templateSaving = ref(false)
|
||
const templatePreviewLoading = ref(false)
|
||
const templateGenerateLoading = ref(false)
|
||
const templatePreview = ref<Array<{ date?: string; etdAt: string; cutoffAt: string; voyageNo?: string | null; sailingNo?: string | null; conflict?: boolean; conflictReason?: string | null }>>([])
|
||
const editingTemplateId = ref('')
|
||
const templateForm = reactive<SailingTemplateSaveRequest>({ templateName: '', channelId: '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '', departurePortCode: '', destinationPortCode: '', remark: '' })
|
||
const generationFrom = ref(formatDate(today))
|
||
const generationTo = ref(addDays(formatDate(today), 56))
|
||
|
||
function sailingDialogTitle(action: string) { const code = selectedSailing.value?.sailingNo?.trim(); return code ? `${action} · ${code}` : action }
|
||
const templateDialogTitle = computed(() => { const code = templates.value.find((item) => item.id === editingTemplateId.value)?.templateNo?.trim(); const action = editingTemplateId.value ? '编辑周模板' : '新增周模板'; return code ? `${action} · ${code}` : action })
|
||
|
||
function formatMonth(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}` }
|
||
function formatDate(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}` }
|
||
function shanghaiToday() {
|
||
const parts = new Intl.DateTimeFormat('en', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date())
|
||
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((item) => item.type === type)?.value)
|
||
return new Date(part('year'), part('month') - 1, part('day'))
|
||
}
|
||
function addDays(value: string, days: number) { const date = new Date(`${value}T00:00:00`); date.setDate(date.getDate() + days); return formatDate(date) }
|
||
function resetGenerationRange(from = formatDate(today)) { const todayKey = formatDate(today); generationFrom.value = from && from > todayKey ? from : todayKey; generationTo.value = addDays(generationFrom.value, 56) }
|
||
function setGenerationWeeks(weeks: number) { generationTo.value = addDays(generationFrom.value || formatDate(today), weeks * 7) }
|
||
function parseMonth(value: string) { const [year, monthNumber] = value.split('-').map(Number); return new Date(year, monthNumber - 1, 1) }
|
||
function dateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||
function decimal(value: number) { return `${Number(value || 0).toFixed(2)} m³` }
|
||
function statusLabel(value: SailingStatus) { return ({ DRAFT: '草稿', OPEN: '开放收货', CLOSED: '已截止', DEPARTED: '已开船', CANCELLED: '已取消', COMPLETED: '已完成' } satisfies Record<SailingStatus, string>)[value] }
|
||
function statusType(value: SailingStatus) { return value === 'OPEN' ? 'success' : value === 'CANCELLED' ? 'danger' : value === 'CLOSED' ? 'warning' : 'info' }
|
||
function blankForm(): SailingSaveRequest {
|
||
const base = selectedDate.value || formatDate(today)
|
||
const etdAt = `${base}T10:00:00`
|
||
return { templateId: null, channelId: null, warehouseId: '', warehouseName: '', channelCode: '', channelName: '', vesselName: '', voyageNo: '', departurePortCode: '', departurePortName: '', destinationPortCode: '', destinationPortName: '', etdAt, etaAt: null, cutoffAt: cutoffFor(etdAt), sourceType: 'MANUAL', plannedCapacityCbm: null, remark: '' }
|
||
}
|
||
function cutoffFor(etdAt: string) {
|
||
const value = new Date(etdAt)
|
||
value.setDate(value.getDate() - cutoffDays.value)
|
||
const [hours, minutes, seconds] = cutoffLocalTime.value.split(':').map(Number)
|
||
value.setHours(hours || 0, minutes || 0, seconds || 0, 0)
|
||
return `${formatDate(value)}T${cutoffLocalTime.value}`
|
||
}
|
||
function syncCutoffFromRule() { if (cutoffMode.value === 'rule' && form.etdAt) form.cutoffAt = cutoffFor(form.etdAt) }
|
||
function onCutoffModeChanged() { syncCutoffFromRule() }
|
||
function onCutoffRuleChanged() { syncCutoffFromRule() }
|
||
function onCustomCutoffChanged() { cutoffMode.value = 'custom' }
|
||
|
||
const filters = computed<SailingCalendarFilterOptions>(() => data.value?.filterOptions || { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] })
|
||
const recordsByDate = computed(() => {
|
||
const map = new Map<string, SailingCalendarEntry[]>()
|
||
for (const record of data.value?.records || []) {
|
||
const date = record.etdAt.slice(0, 10)
|
||
const existing = map.get(date) || []
|
||
existing.push(record)
|
||
map.set(date, existing)
|
||
}
|
||
return map
|
||
})
|
||
const cells = computed<CalendarCell[]>(() => {
|
||
const first = parseMonth(month.value)
|
||
const offset = (first.getDay() + 6) % 7
|
||
const count = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate()
|
||
const total = Math.ceil((offset + count) / 7) * 7
|
||
return Array.from({ length: total }, (_, index) => {
|
||
const date = new Date(first.getFullYear(), first.getMonth(), index - offset + 1)
|
||
const dateKey = formatDate(date)
|
||
return { date: dateKey, day: date.getDate(), currentMonth: date.getMonth() === first.getMonth(), records: recordsByDate.value.get(dateKey) || [] }
|
||
})
|
||
})
|
||
const selectedDateRecords = computed(() => recordsByDate.value.get(selectedDate.value) || [])
|
||
const adjacentSailings = computed(() => {
|
||
const current = selectedSailing.value
|
||
if (!current) return []
|
||
const currentTime = new Date(current.etdAt).getTime()
|
||
return (data.value?.records || [])
|
||
.filter((record) => record.id !== current.id
|
||
&& record.status !== 'CANCELLED'
|
||
&& record.warehouseId === current.warehouseId
|
||
&& record.channelCode === current.channelCode
|
||
&& record.departurePortCode === current.departurePortCode
|
||
&& record.destinationPortCode === current.destinationPortCode)
|
||
.sort((left, right) => Math.abs(new Date(left.etdAt).getTime() - currentTime) - Math.abs(new Date(right.etdAt).getTime() - currentTime))
|
||
.slice(0, 3)
|
||
})
|
||
function referenceVolume(record: unknown) {
|
||
const sailing = record as SailingCalendarEntry
|
||
return Number(sailing.volumeSummary.catchableReceivedCbm || 0) + Number(sailing.volumeSummary.catchableForecastCbm || 0)
|
||
}
|
||
function remainingCapacity(record: unknown) {
|
||
const sailing = record as SailingCalendarEntry
|
||
const capacity = Number(sailing.volumeSummary.planningCapacityCbm || 0)
|
||
return Math.max(capacity - referenceVolume(record), 0)
|
||
}
|
||
const currentRemainingCapacity = computed(() => selectedSailing.value ? remainingCapacity(selectedSailing.value) : 0)
|
||
function mergeAdvice(record: unknown) {
|
||
const current = selectedSailing.value
|
||
const sailing = record as SailingCalendarEntry
|
||
if (!current || current.status !== 'OPEN' || sailing.status !== 'OPEN') return '仅供对比'
|
||
const currentCapacity = Number(current.volumeSummary.planningCapacityCbm || 0)
|
||
return currentCapacity > 0 && referenceVolume(current) < currentCapacity && remainingCapacity(sailing) > 0 ? '可人工评估合船' : '仅供对比'
|
||
}
|
||
const canCreate = computed(() => access.can('platform:firstleg:sailing-calendar:create'))
|
||
const canEdit = computed(() => access.can('platform:firstleg:sailing-calendar:edit'))
|
||
const canCopy = computed(() => access.can('platform:firstleg:sailing-calendar:create'))
|
||
const canManageTemplates = computed(() => access.can('platform:firstleg:sailing-template:manage'))
|
||
|
||
// 月份或筛选快速切换时只接收最后一次请求,旧响应不能覆盖当前日历和刷新节点。
|
||
async function load() {
|
||
const request = ++calendarRequest
|
||
const requestedMonth = month.value
|
||
const isCurrent = () => !disposed && request === calendarRequest && requestedMonth === month.value
|
||
clearTimeout(refreshTimer)
|
||
today = shanghaiToday()
|
||
loading.value = true
|
||
error.value = null
|
||
try {
|
||
const result = await listSailingCalendar({ month: requestedMonth, warehouseId: query.warehouseId || undefined, channelCode: query.channelCode || undefined, departurePortCode: query.departurePortCode || undefined, destinationPortCode: query.destinationPortCode || undefined, keyword: query.keyword.trim() || undefined, status: query.status || undefined })
|
||
if (!isCurrent()) return
|
||
data.value = result
|
||
const todayKey = formatDate(today)
|
||
const isCurrentMonth = requestedMonth === formatMonth(today)
|
||
// 保留用户查看或复制定位的有效日期;只有跨月或日期已过期时才重新定位。
|
||
if (!selectedDate.value.startsWith(requestedMonth) || (isCurrentMonth && selectedDate.value < todayKey)) {
|
||
selectedDate.value = isCurrentMonth ? todayKey : result.records[0]?.etdAt.slice(0, 10) || `${requestedMonth}-01`
|
||
}
|
||
if (selectedSailing.value) {
|
||
selectedSailing.value = result.records.find((record) => record.id === selectedSailing.value?.id && record.etdAt.slice(0, 10) === selectedDate.value) || null
|
||
if (!selectedSailing.value) { detailOpen.value = false; historyOpen.value = false; adviceOpen.value = false }
|
||
}
|
||
} catch (cause) {
|
||
if (isCurrent()) error.value = getApiErrorDetails(cause, '船期日历加载失败')
|
||
} finally {
|
||
if (isCurrent()) {
|
||
loading.value = false
|
||
scheduleRefresh()
|
||
}
|
||
}
|
||
}
|
||
function scheduleRefresh() {
|
||
if (disposed) return
|
||
clearTimeout(refreshTimer)
|
||
const nextRefresh = data.value ? new Date(`${data.value.nextRefreshAt}+08:00`).getTime() : Number.NaN
|
||
// 服务端统一返回跨月、跨筛选的货量变化节点;失败时延迟重试,避免紧密刷新。
|
||
const delay = !error.value && Number.isFinite(nextRefresh) ? Math.max(1000, nextRefresh - Date.now()) : 60_000
|
||
refreshTimer = setTimeout(() => {
|
||
selectedSailing.value = null
|
||
detailOpen.value = false; historyOpen.value = false; adviceOpen.value = false
|
||
const currentMonth = formatMonth(shanghaiToday())
|
||
if (month.value === formatMonth(today) && month.value !== currentMonth) month.value = currentMonth
|
||
else void load()
|
||
}, delay)
|
||
}
|
||
function changeMonth(delta: number) {
|
||
const next = parseMonth(month.value)
|
||
next.setMonth(next.getMonth() + delta)
|
||
month.value = formatMonth(next)
|
||
selectedDate.value = `${month.value}-01`
|
||
}
|
||
function goToday() {
|
||
today = shanghaiToday()
|
||
selectedDate.value = formatDate(today)
|
||
if (month.value === formatMonth(today)) void load()
|
||
else month.value = formatMonth(today)
|
||
}
|
||
function selectDate(cell: CalendarCell) { selectedDate.value = cell.date; selectedSailing.value = null }
|
||
function selectSailing(record: SailingCalendarEntry) { selectedSailing.value = record }
|
||
function resetFilters() { Object.assign(query, { warehouseId: '', channelCode: '', departurePortCode: '', destinationPortCode: '', keyword: '', status: '' }); void load() }
|
||
async function openManagement(tab: 'channels' | 'templates' = 'channels') {
|
||
managementTab.value = tab
|
||
managementOpen.value = true
|
||
managementError.value = null
|
||
managementLoading.value = true
|
||
try {
|
||
const [channelRows, templateRows] = await Promise.all([listSailingChannels(), listSailingTemplates(), ensureWarehouseOptions()])
|
||
channels.value = channelRows
|
||
templates.value = templateRows
|
||
} catch (cause) { managementError.value = getApiErrorDetails(cause, '渠道与周模板加载失败') } finally { managementLoading.value = false }
|
||
}
|
||
async function ensureWarehouseOptions() {
|
||
if (warehousesLoaded.value) return
|
||
if (warehouseRequest) return warehouseRequest
|
||
warehouseLoading.value = true
|
||
warehouseRequest = listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
|
||
.then((rows) => { warehouseOptions.value = rows; warehousesLoaded.value = true })
|
||
.finally(() => { warehouseLoading.value = false; warehouseRequest = null })
|
||
return warehouseRequest
|
||
}
|
||
function warehouseLabel(id: string) {
|
||
const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(id))
|
||
return warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : '未关联收货仓'
|
||
}
|
||
function applyWarehouse(id: string, target: { warehouseId: string; warehouseName?: string | null }) {
|
||
target.warehouseId = String(id || '')
|
||
const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(id))
|
||
target.warehouseName = warehouse?.warehouseName || target.warehouseName || ''
|
||
}
|
||
function syncTemplateDerivedFields() {
|
||
const channel = channels.value.find((item) => item.id === String(templateForm.channelId))
|
||
if (channel) {
|
||
templateForm.departurePortCode = templateForm.departurePortCode || channel.defaultDeparturePortCode || ''
|
||
templateForm.destinationPortCode = templateForm.destinationPortCode || channel.defaultDestinationPortCode || ''
|
||
templateForm.vesselName = templateForm.vesselName || '待订舱确认'
|
||
}
|
||
const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(templateForm.warehouseId))
|
||
if (warehouse) templateForm.warehouseName = warehouse.warehouseName
|
||
}
|
||
function onTemplateChannelChanged(id: string) {
|
||
const channel = channels.value.find((item) => item.id === String(id))
|
||
if (!channel) return
|
||
templateForm.cutoffDays = channel.defaultCutoffDays
|
||
templateForm.departurePortCode = channel.defaultDeparturePortCode || ''
|
||
templateForm.destinationPortCode = channel.defaultDestinationPortCode || ''
|
||
templateForm.vesselName = '待订舱确认'
|
||
}
|
||
async function loadChannelsForWarehouse(warehouseId?: string) {
|
||
channels.value = warehouseId ? await listSailingChannels(warehouseId) : []
|
||
}
|
||
async function onTemplateWarehouseChanged(id: string) {
|
||
applyWarehouse(id, templateForm)
|
||
templateForm.channelId = ''
|
||
templateForm.departurePortCode = ''
|
||
templateForm.destinationPortCode = ''
|
||
await loadChannelsForWarehouse(id)
|
||
}
|
||
async function onFormWarehouseChanged(id: string) {
|
||
applyWarehouse(id, form)
|
||
form.channelCode = ''
|
||
form.channelId = null
|
||
await loadChannelsForWarehouse(id)
|
||
}
|
||
async function openTemplateCreate() {
|
||
await ensureWarehouseOptions()
|
||
editingTemplateId.value = ''
|
||
Object.assign(templateForm, { templateName: '', channelId: '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '待订舱确认', departurePortCode: '', destinationPortCode: '', remark: '', expectedVersion: undefined })
|
||
channels.value = []
|
||
templatePreview.value = []
|
||
resetGenerationRange(templateForm.effectiveFrom)
|
||
templateDialogOpen.value = true
|
||
}
|
||
async function openTemplateEdit(row: unknown, options: { preserveGenerationRange?: boolean } = {}) {
|
||
const templateRow = row as SailingTemplateResource
|
||
editingTemplateId.value = templateRow.id
|
||
Object.assign(templateForm, { templateName: templateRow.templateName || '', channelId: templateRow.channelId, warehouseId: templateRow.warehouseId, warehouseName: templateRow.warehouseName || '', weekdays: [...templateRow.weekdays], effectiveFrom: templateRow.effectiveFrom, effectiveTo: templateRow.effectiveTo || '', departureTime: templateRow.departureTime, cutoffDays: templateRow.cutoffDays, cutoffLocalTime: templateRow.cutoffLocalTime, voyageRule: templateRow.voyageRule || '', vesselName: templateRow.vesselName || '', departurePortCode: templateRow.departurePortCode || '', destinationPortCode: templateRow.destinationPortCode || '', remark: templateRow.remark || '', expectedVersion: templateRow.version })
|
||
templatePreview.value = []
|
||
if (!options.preserveGenerationRange) resetGenerationRange(templateForm.effectiveFrom)
|
||
try { await loadChannelsForWarehouse(templateForm.warehouseId) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道加载失败').message); return }
|
||
templateDialogOpen.value = true
|
||
}
|
||
function templatePayload() { syncTemplateDerivedFields(); return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b), voyageRule: templateForm.voyageRule || null, vesselName: templateForm.vesselName || '待订舱确认', remark: templateForm.remark || null } }
|
||
function validateTemplate() { syncTemplateDerivedFields(); if (!templateForm.channelId || !templateForm.warehouseId || !templateForm.weekdays.length || !templateForm.effectiveFrom || !templateForm.departureTime || templateForm.cutoffDays < 0 || !templateForm.departurePortCode?.trim() || !templateForm.destinationPortCode?.trim()) { ElMessage.error('请补全渠道、收货仓、周几、有效期、开船时间、起运港和目的港'); return false } if (templateForm.cutoffDays === 0 && templateForm.cutoffLocalTime >= templateForm.departureTime) { ElMessage.error('同日收货截止时刻必须早于开船时间'); return false } return true }
|
||
async function saveTemplate() {
|
||
if (!validateTemplate()) return
|
||
templateSaving.value = true
|
||
try {
|
||
const payload = templatePayload()
|
||
const existingId = editingTemplateId.value
|
||
if (existingId) await updateSailingTemplate(existingId, payload)
|
||
else await createSailingTemplate(payload)
|
||
ElMessage.success(existingId ? '周模板已更新' : '周模板已创建')
|
||
templateDialogOpen.value = false
|
||
await openManagement('templates')
|
||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '周模板保存失败').message) } finally { templateSaving.value = false }
|
||
}
|
||
function generationRange() { return { from: generationFrom.value, to: generationTo.value } }
|
||
async function previewTemplate(row?: unknown) {
|
||
const templateRow = row as SailingTemplateResource | undefined
|
||
if (templateRow) openTemplateEdit(templateRow)
|
||
if (!validateTemplate()) return
|
||
if (!generationFrom.value || !generationTo.value || generationFrom.value > generationTo.value) { ElMessage.error('请填写有效的本次生成起止日期'); return }
|
||
const templateId = templateRow?.id || templates.value.find((item) => item.channelId === templateForm.channelId && item.warehouseId === templateForm.warehouseId && item.version === templateForm.expectedVersion)?.id
|
||
if (!templateId) { ElMessage.info('请先保存周模板,再进行生成预览'); return }
|
||
templatePreviewLoading.value = true
|
||
try { const result = await previewSailingTemplateGeneration(templateId, generationRange()); templatePreview.value = result.records || []; templateDialogOpen.value = true } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '生成预览失败').message) } finally { templatePreviewLoading.value = false }
|
||
}
|
||
async function generateTemplate(row: unknown) {
|
||
const templateRow = row as SailingTemplateResource
|
||
openTemplateEdit(templateRow)
|
||
}
|
||
async function confirmTemplateGeneration() {
|
||
const templateId = editingTemplateId.value
|
||
if (!templateId) { ElMessage.info('请先保存周模板,再生成船期'); return }
|
||
if (!generationFrom.value || !generationTo.value || generationFrom.value > generationTo.value) { ElMessage.error('请填写有效的本次生成起止日期'); return }
|
||
await ElMessageBox.confirm(`确认按当前模板生成 ${generationFrom.value} 至 ${generationTo.value} 的船期?重复日期会由服务端跳过。`, '批量生成船期', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' })
|
||
templateGenerateLoading.value = true
|
||
try {
|
||
const result = await generateSailingTemplate(templateId, generationRange())
|
||
ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`)
|
||
templateDialogOpen.value = false
|
||
await load()
|
||
await openManagement('templates')
|
||
} finally { templateGenerateLoading.value = false }
|
||
}
|
||
function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.map((day: unknown) => ['一', '二', '三', '四', '五', '六', '日'][Number(day) - 1]).join('、') : '—' }
|
||
function onChannelChanged(value: string) {
|
||
const channel = channels.value.find((item) => item.channelCode === value)
|
||
form.channelName = channel?.channelName || ''
|
||
form.channelId = channel?.id || null
|
||
form.departurePortCode = channel?.defaultDeparturePortCode || ''
|
||
form.destinationPortCode = channel?.defaultDestinationPortCode || ''
|
||
form.departurePortName = ''
|
||
form.destinationPortName = ''
|
||
form.vesselName = null
|
||
form.voyageNo = null
|
||
cutoffDays.value = channel?.defaultCutoffDays ?? 3
|
||
syncCutoffFromRule()
|
||
routeExpanded.value = form.departurePortCode && form.destinationPortCode ? [] : ['route']
|
||
}
|
||
// 新增表单独立于日历选择,清除上次编辑目标。
|
||
async function openCreate() {
|
||
editingSailing.value = null
|
||
dialogMode.value = 'create'
|
||
cutoffMode.value = 'rule'
|
||
cutoffDays.value = 3
|
||
cutoffLocalTime.value = '18:00:00'
|
||
Object.assign(form, blankForm(), { expectedVersion: undefined })
|
||
channels.value = []
|
||
routeExpanded.value = []
|
||
dialogOpen.value = true
|
||
formOptionsLoading.value = true
|
||
try {
|
||
await ensureWarehouseOptions()
|
||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败,请关闭后重试').message) }
|
||
finally { formOptionsLoading.value = false }
|
||
}
|
||
// 编辑目标和版本在打开表单时固定,自动刷新只更新日历,不改变本次提交对象。
|
||
async function openEdit(record: SailingCalendarEntry) { try { await ensureWarehouseOptions(); await loadChannelsForWarehouse(record.warehouseId) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败').message); return } editingSailing.value = { id: record.id, sailingNo: record.sailingNo }; routeExpanded.value = []; dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true }
|
||
function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() }
|
||
function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true }
|
||
// 必须实际调用写接口成功后才关闭弹窗;缺失编辑目标时保留表单并提示。
|
||
async function save() {
|
||
if (!validateForm()) return
|
||
const editingId = editingSailing.value?.id
|
||
if (dialogMode.value === 'edit' && !editingId) {
|
||
ElMessage.error('未找到正在编辑的船期,请关闭后重新打开')
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
if (dialogMode.value === 'create') await createSailing({ ...form, expectedVersion: undefined })
|
||
else await updateSailing(editingId!, { ...form })
|
||
ElMessage.success(dialogMode.value === 'create' ? '船期已新增' : '船期已更新')
|
||
dialogOpen.value = false
|
||
await load()
|
||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '船期保存失败').message) } finally { saving.value = false }
|
||
}
|
||
async function cancelSelected() {
|
||
const record = selectedSailing.value
|
||
if (!record || !canEdit.value) return
|
||
try {
|
||
await ElMessageBox.confirm(`确认取消船期“${record.vesselName || '待订舱确认'} / ${record.voyageNo || '待订舱确认'}”?已产生的归班历史会保留。`, '取消船期', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认取消', cancelButtonText: '返回' })
|
||
await cancelSailing(record.id, record.version, '运营人员取消船期')
|
||
ElMessage.success('船期已取消')
|
||
selectedSailing.value = null
|
||
await load()
|
||
} catch (cause) { if (cause !== 'cancel') ElMessage.error(getApiErrorDetails(cause, '取消船期失败').message) }
|
||
}
|
||
async function copySelected() {
|
||
const record = selectedSailing.value
|
||
if (!record || !canCopy.value) return
|
||
const etd = new Date(record.etdAt); etd.setDate(etd.getDate() + 7)
|
||
const etdAt = `${formatDate(etd)}T${record.etdAt.slice(11, 19)}`
|
||
const offsetMs = new Date(record.etdAt).getTime() - new Date(record.cutoffAt).getTime()
|
||
const copiedCutoff = new Date(new Date(etdAt).getTime() - offsetMs)
|
||
const cutoffAt = `${formatDate(copiedCutoff)}T${String(copiedCutoff.getHours()).padStart(2, '0')}:${String(copiedCutoff.getMinutes()).padStart(2, '0')}:${String(copiedCutoff.getSeconds()).padStart(2, '0')}`
|
||
try {
|
||
const result = await copySailing(record.id, { etdAt, cutoffAt })
|
||
const copied = result.sailing
|
||
const targetMonth = copied.etdAt.slice(0, 7)
|
||
month.value = targetMonth
|
||
selectedDate.value = copied.etdAt.slice(0, 10)
|
||
selectedSailing.value = copied
|
||
if (targetMonth === data.value?.month) await load()
|
||
ElMessage.success(`已定位到下一班:${copied.etdAt.slice(0, 16).replace('T', ' ')}`)
|
||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) }
|
||
}
|
||
// 详情请求绑定本次船期和弹窗,关闭重开或换船后忽略此前尚未完成的请求。
|
||
async function openVolumeDetail() {
|
||
const sailingId = selectedSailing.value?.id
|
||
if (!sailingId) return
|
||
const request = ++volumeRequest
|
||
const isCurrent = () => !disposed && request === volumeRequest && detailOpen.value && selectedSailing.value?.id === sailingId
|
||
detailOpen.value = true; detailLoading.value = true; volumeDetail.value = null
|
||
try {
|
||
const result = await getSailingVolumeDetail(sailingId)
|
||
if (isCurrent()) volumeDetail.value = result
|
||
} catch (cause) {
|
||
if (isCurrent()) ElMessage.error(getApiErrorDetails(cause, '货量明细加载失败').message)
|
||
} finally { if (isCurrent()) detailLoading.value = false }
|
||
}
|
||
|
||
// 变更历史只展示当前弹窗对应船期的数据,过时响应不再触发提示。
|
||
async function openHistory() {
|
||
const sailingId = selectedSailing.value?.id
|
||
if (!sailingId) return
|
||
const request = ++historyRequest
|
||
const isCurrent = () => !disposed && request === historyRequest && historyOpen.value && selectedSailing.value?.id === sailingId
|
||
historyOpen.value = true; historyLoading.value = true; histories.value = []
|
||
try {
|
||
const result = await getSailing(sailingId)
|
||
if (isCurrent()) histories.value = result.histories || []
|
||
} catch (cause) {
|
||
if (isCurrent()) ElMessage.error(getApiErrorDetails(cause, '变更历史加载失败').message)
|
||
} finally { if (isCurrent()) historyLoading.value = false }
|
||
}
|
||
// 订柜建议必须与当前选中的船期一致,避免旧船建议覆盖新船。
|
||
async function openBookingAdvice() {
|
||
const sailingId = selectedSailing.value?.id
|
||
if (!sailingId) return
|
||
const request = ++adviceRequest
|
||
const isCurrent = () => !disposed && request === adviceRequest && adviceOpen.value && selectedSailing.value?.id === sailingId
|
||
adviceOpen.value = true; adviceLoading.value = true; bookingAdvice.value = null
|
||
try {
|
||
const result = await getSailingBookingAdvice(sailingId)
|
||
if (isCurrent()) bookingAdvice.value = result
|
||
} catch (cause) {
|
||
if (isCurrent()) ElMessage.error(getApiErrorDetails(cause, '订柜建议加载失败').message)
|
||
} finally { if (isCurrent()) adviceLoading.value = false }
|
||
}
|
||
watch(month, () => void load())
|
||
watch(() => [query.warehouseId, query.channelCode, query.departurePortCode, query.destinationPortCode, query.status], () => void load())
|
||
onMounted(() => void load())
|
||
onUnmounted(() => { disposed = true; clearTimeout(refreshTimer) })
|
||
</script>
|
||
|
||
<template>
|
||
<AdminPage as="div" class="sailing-calendar-page admin-density admin-workbench">
|
||
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" :data="[]" :columns="[]" column-settings-mode="hide" :loading="loading" :results-visible="!error">
|
||
<template #toolbar-start><h1>船期日历</h1></template>
|
||
<template #toolbar-actions>
|
||
<div class="toolbar-actions">
|
||
<el-button v-if="canManageTemplates" @click="openManagement('channels')">渠道与周模板</el-button>
|
||
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增单个船次</el-button>
|
||
</div>
|
||
</template>
|
||
<template #filters>
|
||
<div class="toolbar-filters query-toolbar" aria-label="船期日历筛选">
|
||
<el-select v-model="query.warehouseId" clearable filterable placeholder="全部收货仓" class="filter-select"><el-option v-for="option in filters.warehouses" :key="option.value" :label="option.label" :value="option.value" /></el-select>
|
||
<el-select v-model="query.channelCode" clearable filterable placeholder="全部渠道" class="filter-select"><el-option v-for="option in filters.channels" :key="option.value" :label="option.label" :value="option.value" /></el-select>
|
||
<el-select v-model="query.departurePortCode" clearable filterable placeholder="全部起运港" class="filter-select"><el-option v-for="option in filters.departurePorts" :key="option.value" :label="option.label" :value="option.value" /></el-select>
|
||
<el-select v-model="query.destinationPortCode" clearable filterable placeholder="全部目的港" class="filter-select"><el-option v-for="option in filters.destinationPorts" :key="option.value" :label="option.label" :value="option.value" /></el-select>
|
||
<el-select v-model="query.status" clearable placeholder="全部状态" class="filter-select"><el-option label="开放收货" value="OPEN" /><el-option label="已截止" value="CLOSED" /><el-option label="已开船" value="DEPARTED" /><el-option label="已取消" value="CANCELLED" /></el-select>
|
||
<el-input v-model="query.keyword" clearable placeholder="渠道 / 船名 / 航次 / 船期编号" class="filter-keyword" @keyup.enter="load" />
|
||
<el-button @click="resetFilters">重置</el-button>
|
||
</div>
|
||
</template>
|
||
<template #notices>
|
||
<LoadErrorState v-if="error" title="船期日历加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
</template>
|
||
<template #results>
|
||
<div class="calendar-layout" :aria-busy="loading">
|
||
<section class="calendar-panel">
|
||
<div class="calendar-head"><div class="month-switch"><el-button circle text aria-label="上个月" @click="changeMonth(-1)"><ChevronLeft :size="18" /></el-button><strong>{{ month.replace('-', '年') }}月</strong><el-button circle text aria-label="下个月" @click="changeMonth(1)"><ChevronRight :size="18" /></el-button><el-button size="small" @click="goToday">今天</el-button></div><span class="calendar-note">仅今天及以后 · 时区:{{ data?.timezone || 'Asia/Shanghai' }} · 共 {{ data?.summary.sailingCount || 0 }} 个船期</span></div>
|
||
<div class="week-head"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
|
||
<div class="calendar-grid" :class="{ loading }"><button v-for="cell in cells" :key="cell.date" type="button" class="calendar-cell" :class="{ muted: !cell.currentMonth, selected: selectedDate === cell.date }" @click="selectDate(cell)"><span class="day-number">{{ cell.day }}</span><div v-if="cell.records.length" class="cell-records"><span class="cell-count"><i />{{ cell.records.length }} 个船期</span><span class="cell-route">{{ cell.records[0].departurePortCode }} → {{ cell.records[0].destinationPortCode }}<em v-if="cell.records.length > 1">等</em></span><span v-if="cell.records.some((record) => record.volumeSummary.overCapacity)" class="cell-warning">超容量</span></div></button></div>
|
||
</section>
|
||
<aside class="detail-panel"><div class="detail-header"><div><h2>{{ selectedDate }}</h2><p>当天共有 {{ selectedDateRecords.length }} 个船期</p></div></div><div v-if="!selectedDateRecords.length" class="empty-detail">当天暂无船期</div><div v-else class="sailing-list"><button v-for="record in selectedDateRecords" :key="record.id" type="button" class="sailing-card" :class="{ active: selectedSailing?.id === record.id }" @click="selectSailing(record)"><div class="sailing-card-head"><strong>{{ record.vesselName || '待订舱确认' }}</strong><el-tag size="small" :type="statusType(record.status)">{{ statusLabel(record.status) }}</el-tag></div><div class="voyage">航次 {{ record.voyageNo || '待订舱确认' }} · {{ record.sailingNo }}</div><div class="route-line">{{ record.departurePortCode }} <b>→</b> {{ record.destinationPortCode }}</div><div class="cutoff-line">开船 {{ dateTime(record.etdAt) }} · 截止 {{ dateTime(record.cutoffAt) }}</div><div class="volume-line"><span>待发 <b>{{ decimal(record.volumeSummary.actualReceivedCbm) }}</b></span><span>建议 {{ record.volumeSummary.recommendedContainerCount }} 柜</span></div></button></div><div v-if="selectedSailing" class="detail-actions density-actions"><el-button size="small" @click="openVolumeDetail"><Eye :size="15" />货量明细</el-button><el-button size="small" @click="openHistory">变更历史</el-button><el-button size="small" @click="openBookingAdvice">订柜建议</el-button><el-button v-if="canEdit" size="small" @click="openEdit(selectedSailing)"><Pencil :size="15" />编辑</el-button><el-button v-if="canCopy" size="small" @click="copySelected"><Copy :size="15" />复制下一班</el-button><el-button v-if="canEdit && selectedSailing.status !== 'CANCELLED'" size="small" type="danger" plain @click="cancelSelected"><XCircle :size="15" />取消</el-button></div></aside>
|
||
</div>
|
||
</template>
|
||
</DataTable></div>
|
||
|
||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="sailingEditorTitle" width="760px" destroy-on-close fullscreenable :close-on-click-modal="true" :lock-while-submitting="false" :confirm-loading="saving" :confirm-disabled="formOptionsLoading" confirm-text="保存" @confirm="save">
|
||
<el-form v-loading="formOptionsLoading" class="density-form">
|
||
<section class="density-dialog-section" data-tone="work">
|
||
<div class="workbench-evidence"><table class="density-table density-table--editable sailing-entry-table" aria-label="船期资料">
|
||
<colgroup><col class="sailing-entry-label" /><col /><col class="sailing-entry-label" /><col /></colgroup>
|
||
<tbody>
|
||
<tr>
|
||
<th scope="row"><label for="sailing-warehouse">收货仓 *</label></th><td><el-select id="sailing-warehouse" v-model="form.warehouseId" filterable placeholder="请选择收货仓" @change="onFormWarehouseChanged"><el-option v-if="form.warehouseId && !warehouseOptions.some((item) => String(item.id) === form.warehouseId)" :value="form.warehouseId" :label="form.warehouseName || '原收货仓(已不可选)'" /><el-option v-for="warehouse in warehouseOptions" :key="String(warehouse.id)" :label="warehouseLabel(String(warehouse.id))" :value="String(warehouse.id)" /></el-select></td>
|
||
<th scope="row"><label for="sailing-channel">渠道 *</label></th><td><el-select id="sailing-channel" v-model="form.channelCode" filterable clearable placeholder="请选择已有渠道" @change="onChannelChanged"><el-option v-for="channel in channels.filter((item) => item.status === 'ACTIVE' || item.channelCode === form.channelCode)" :key="channel.channelCode" :label="`${channel.channelName}(${channel.channelCode})`" :value="channel.channelCode" /></el-select></td>
|
||
</tr>
|
||
<tr><th scope="row">船名 / 航次</th><td colspan="3"><template v-if="form.vesselName || form.voyageNo">{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}</template><span v-else class="sailing-pending">待订舱确认</span></td></tr>
|
||
<tr><th scope="row">航线资料</th><td colspan="3"><el-collapse v-model="routeExpanded"><el-collapse-item name="route" :title="`${form.departurePortCode || '待补起运港'} → ${form.destinationPortCode || '待补目的港'}`"><el-row :gutter="12"><el-col :span="12"><el-form-item label="起运港" required><el-input v-model="form.departurePortCode" placeholder="价格表未配置时补充,如 CNNGB" /></el-form-item></el-col><el-col :span="12"><el-form-item label="目的港" required><el-input v-model="form.destinationPortCode" placeholder="渠道未配置时补充,如 USLGB" /></el-form-item></el-col></el-row></el-collapse-item></el-collapse></td></tr>
|
||
<tr><th scope="row"><label for="sailing-etd">预计开船 *</label></th><td colspan="3"><div class="density-table-editor--outlined sailing-date-editor"><el-date-picker id="sailing-etd" v-model="form.etdAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" @change="onEtdChanged" /></div></td></tr>
|
||
<tr><th id="sailing-cutoff-label" scope="row">收货截止规则 *</th><td colspan="3"><div class="cutoff-config" role="group" aria-labelledby="sailing-cutoff-label">
|
||
<el-radio-group v-model="cutoffMode" class="cutoff-mode-group" aria-label="收货截止计算方式" @change="onCutoffModeChanged">
|
||
<el-radio label="rule">按开船日期倒推</el-radio>
|
||
<el-radio label="custom">自定义时间</el-radio>
|
||
</el-radio-group>
|
||
<template v-if="cutoffMode === 'rule'">
|
||
<div class="cutoff-rule-row">
|
||
<span id="sailing-cutoff-presets-label" class="cutoff-control-label">提前天数</span>
|
||
<el-radio-group v-model="cutoffDays" class="cutoff-presets" aria-labelledby="sailing-cutoff-presets-label" @change="onCutoffRuleChanged">
|
||
<el-radio-button :label="2">2 天</el-radio-button>
|
||
<el-radio-button :label="3">3 天</el-radio-button>
|
||
<el-radio-button :label="4">4 天</el-radio-button>
|
||
</el-radio-group>
|
||
<div class="cutoff-custom-days">
|
||
<label for="sailing-cutoff-days" class="cutoff-control-label">自定义</label>
|
||
<div class="density-table-editor density-table-editor--outlined cutoff-days-editor">
|
||
<el-input-number id="sailing-cutoff-days" v-model="cutoffDays" aria-label="自定义提前天数" :min="0" :max="30" controls-position="right" placeholder="天数" @change="onCutoffRuleChanged" />
|
||
<span>天</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="cutoff-time-row">
|
||
<label for="sailing-cutoff-time" class="cutoff-control-label">截止时刻</label>
|
||
<div class="density-table-editor--outlined cutoff-time-editor">
|
||
<el-time-picker id="sailing-cutoff-time" v-model="cutoffLocalTime" value-format="HH:mm:ss" :clearable="false" @change="onCutoffRuleChanged" />
|
||
</div>
|
||
<InlineHelp title="收货截止规则说明"><p>按开船地点的本地日期倒推自然日。</p></InlineHelp>
|
||
</div>
|
||
</template>
|
||
<div v-else class="density-table-editor--outlined sailing-date-editor">
|
||
<el-date-picker v-model="form.cutoffAt" aria-label="自定义收货截止时间" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择收货截止时间" @change="onCustomCutoffChanged" />
|
||
</div>
|
||
</div></td></tr>
|
||
<tr><th scope="row">收货截止时间</th><td colspan="3"><div class="cutoff-calculated" role="status" aria-live="polite"><strong>{{ dateTime(form.cutoffAt) }}</strong><span>{{ cutoffMode === 'rule' ? '按规则计算' : '自定义时间' }}</span></div></td></tr>
|
||
</tbody>
|
||
</table></div>
|
||
</section>
|
||
|
||
</el-form>
|
||
<template #footer><el-button @click="dialogOpen = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="formOptionsLoading" @click="save">保存</el-button></template>
|
||
</AppDialog>
|
||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="detailOpen" :title="sailingDialogTitle('货量明细(只读)')" width="760px" fullscreenable :show-footer="false" :close-on-click-modal="true"><div v-loading="detailLoading" class="density-dialog-sections"><template v-if="volumeDetail"><section class="density-dialog-section" data-tone="summary"><h3 class="density-dialog-section__title">货量汇总<InlineHelp title="货量统计说明"><p>已确认且仍有效的运输安排保留在对应船期;未安排或原船期已过期、取消的余货,顺延至同仓、同渠道下一班尚未截仓的船期。确认离仓后扣减货量,预计量没有有效体积来源时显示 0。</p></InlineHelp></h3><dl class="density-fields sailing-summary"><div><dt>待发实测</dt><dd>{{ decimal(volumeDetail.summary.actualReceivedCbm) }}</dd></div><div><dt>截止前参考</dt><dd>{{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}</dd></div><div><dt>晚到 / 风险</dt><dd>{{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}</dd></div><div><dt>预计参考</dt><dd>{{ decimal(volumeDetail.summary.catchableForecastCbm) }}</dd></div><div><dt>待确认</dt><dd>{{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}</dd></div></dl></section><section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">归属明细</h3><el-table :data="volumeDetail.assignments" size="small" empty-text="暂无已明确归属本船的明细;上方为同仓、同渠道未离仓货量的顺延展示"><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="volumeCbm" label="体积" width="110"><template #default="{ row }">{{ decimal(row.volumeCbm) }}</template></el-table-column><el-table-column prop="status" label="来源状态" width="130" /><el-table-column prop="receivedAt" label="收货时间" min-width="150"><template #default="{ row }">{{ dateTime(row.receivedAt) }}</template></el-table-column></el-table></section></template></div></AppDialog>
|
||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="historyOpen" :title="sailingDialogTitle('船期变更历史')" width="760px" fullscreenable :show-footer="false" :close-on-click-modal="true"><div v-loading="historyLoading"><section class="density-dialog-section" data-tone="source"><el-table :data="histories" size="small" empty-text="暂无变更历史"><el-table-column prop="action" label="动作" width="110" /><el-table-column label="状态变化" width="170"><template #default="{ row }">{{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}</template></el-table-column><el-table-column prop="reason" label="原因" min-width="220" /><el-table-column prop="operatorName" label="操作人" width="120" /><el-table-column prop="createdAt" label="时间" min-width="160"><template #default="{ row }">{{ dateTime(row.createdAt) }}</template></el-table-column></el-table></section></div></AppDialog><AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adviceOpen" :title="sailingDialogTitle('订柜建议(只读初算)')" width="720px" fullscreenable :show-footer="false" :close-on-click-modal="true"><div v-loading="adviceLoading" class="density-dialog-sections"><template v-if="bookingAdvice"><section class="density-dialog-section" data-tone="summary"><h3 class="density-dialog-section__title">订柜初算汇总<InlineHelp title="订柜初算说明"><p>建议柜数按待发实测与可核验预计货量除以柜型容量向上取整,有货不足一柜也按一柜;待补柜先扣除未离仓柜的可用订舱容量。已离仓柜不再抵扣余货需求,最终以智能排柜和人工确认结果为准。</p></InlineHelp></h3><dl class="density-fields sailing-summary"><div><dt>待发实测</dt><dd>{{ decimal(bookingAdvice.actualReceivedCbm || 0) }}</dd></div><div><dt>截止前参考</dt><dd>{{ decimal(bookingAdvice.catchableTotalCbm) }}</dd></div><div><dt>晚到 / 风险</dt><dd>{{ decimal(bookingAdvice.lateReceivedCbm || 0) }}</dd></div><div><dt>已订柜</dt><dd>{{ bookingAdvice.bookedContainerCount }} 柜</dd></div><div><dt>建议补柜</dt><dd>{{ bookingAdvice.pendingContainerCount }} 柜</dd></div><div><dt>规划容量</dt><dd>{{ decimal(bookingAdvice.planningCapacityCbm || 0) }}</dd></div><div><dt>剩余容量</dt><dd>{{ decimal(currentRemainingCapacity) }}</dd></div></dl></section><section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">柜型方案</h3><el-table :data="bookingAdvice.scenarios" size="small"><el-table-column prop="containerTypeName" label="柜型" /><el-table-column prop="capacityCbm" label="规划容量" width="120"><template #default="{ row }">{{ decimal(row.capacityCbm) }}</template></el-table-column><el-table-column prop="recommendedContainerCount" label="建议柜数" width="100" /><el-table-column prop="pendingContainerCount" label="待补柜数" width="100" /></el-table></section><section class="density-dialog-section" data-tone="source"><div class="merge-reference-head"><strong>相邻船期合船参考<InlineHelp title="合船参考说明"><p>仅对比同仓、同渠道、同起运港和目的港的相邻船期。是否合船需在智能排柜中人工确认。</p></InlineHelp></strong></div><el-table :data="adjacentSailings" size="small" empty-text="当前月份没有可对比的相邻船期"><el-table-column label="相邻船期" min-width="190"><template #default="{ row }"><strong>{{ row.vesselName }}</strong><div class="merge-reference-sub">{{ row.voyageNo }} · {{ dateTime(row.etdAt) }}</div></template></el-table-column><el-table-column label="截止前参考" width="110"><template #default="{ row }">{{ decimal(referenceVolume(row)) }}</template></el-table-column><el-table-column label="剩余容量" width="100"><template #default="{ row }">{{ decimal(remainingCapacity(row)) }}</template></el-table-column><el-table-column label="建议" width="125"><template #default="{ row }"><el-tag size="small" :type="mergeAdvice(row) === '可人工评估合船' ? 'success' : 'info'">{{ mergeAdvice(row) }}</el-tag></template></el-table-column></el-table></section></template></div></AppDialog>
|
||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="managementOpen" title="渠道与周模板管理" width="1080px" fullscreenable :show-footer="false" :close-on-click-modal="true"><div v-loading="managementLoading"><el-alert v-if="managementError" :title="managementError.message" type="error" :closable="false" class="management-error"><template #default><ApiErrorMeta :details="managementError" /></template></el-alert><el-tabs v-model="managementTab" class="density-tabs"><el-tab-pane label="渠道" name="channels"><section class="density-dialog-section" data-tone="source"><el-table :data="channels" size="small" empty-text="暂无渠道主数据"><el-table-column prop="channelCode" label="渠道编码" width="140" /><el-table-column prop="channelName" label="渠道名称" min-width="160" show-overflow-tooltip /><el-table-column prop="sourceType" label="来源" width="120" /><el-table-column prop="defaultCutoffDays" label="默认提前天数" width="130" /><el-table-column prop="status" label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template></el-table-column></el-table></section></el-tab-pane><el-tab-pane label="每周模板" name="templates"><div class="management-toolbar"><InlineHelp title="周模板说明"><p>按每周开船日和收货截止生成船期,先预览再确认;重复日期自动跳过。</p></InlineHelp><el-button v-if="canManageTemplates" type="primary" size="small" @click="openTemplateCreate"><Plus :size="14" />新增周模板</el-button></div><section class="density-dialog-section" data-tone="source"><el-table :data="templates" size="small" empty-text="暂无周模板"><el-table-column v-if="canManageTemplates" label="操作" width="220" fixed="left"><template #default="{ row }"><el-button size="small" link type="primary" @click="openTemplateEdit(row)">编辑</el-button><el-button size="small" link type="primary" :loading="templatePreviewLoading" @click="previewTemplate(row)">预览生成</el-button><el-button size="small" link type="success" :loading="templateGenerateLoading" @click="generateTemplate(row)">设置并生成</el-button></template></el-table-column><el-table-column prop="templateNo" label="模板编号" width="140" /><el-table-column prop="channelName" label="渠道" min-width="130" show-overflow-tooltip /><el-table-column prop="warehouseName" label="收货仓" min-width="140" show-overflow-tooltip /><el-table-column label="周几" width="140"><template #default="{ row }">{{ formatWeekdays(row.weekdays) }}</template></el-table-column><el-table-column label="开船 / 截止" width="150"><template #default="{ row }">{{ row.departureTime }} / {{ row.cutoffDays }}天</template></el-table-column><el-table-column prop="status" label="状态" width="100" /></el-table></section></el-tab-pane></el-tabs></div></AppDialog>
|
||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="templateDialogOpen" :title="templateDialogTitle" width="1080px" fullscreenable :close-on-click-modal="true"><div class="density-dialog-sections"><el-form label-position="top" class="density-form density-dialog-sections"><section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">每周规律<InlineHelp title="模板生成说明"><p>模板只保存每周规律;本次生成区间默认未来 8 周,先预览再确认生成,重复日期由服务端跳过。仓库和渠道直接选择已有资料;港口优先从渠道带入,船名未确认时可先排船。</p></InlineHelp></h3><el-row :gutter="12"><el-col :span="8"><el-form-item label="收货仓" required><el-select v-model="templateForm.warehouseId" filterable placeholder="请先选择收货仓" @change="onTemplateWarehouseChanged"><el-option v-for="warehouse in warehouseOptions" :key="String(warehouse.id)" :label="warehouseLabel(String(warehouse.id))" :value="String(warehouse.id)" /></el-select></el-form-item></el-col><el-col :span="16"><el-form-item label="渠道" required><template #label><span>渠道<InlineHelp title="渠道选择说明"><p>渠道来自当前生效的通用船期价格表,并按收货仓的有效报价筛选。</p></InlineHelp></span></template><el-select v-model="templateForm.channelId" filterable :disabled="!templateForm.warehouseId" placeholder="请选择当前生效价格表渠道" @change="onTemplateChannelChanged"><el-option v-for="row in channels.filter((item) => item.status === 'ACTIVE')" :key="row.id" :label="`${row.channelName} (${row.channelCode})`" :value="row.id" /></el-select></el-form-item></el-col><el-col :span="24"><el-form-item label="每周开船日" required><el-checkbox-group v-model="templateForm.weekdays"><el-checkbox v-for="day in [1, 2, 3, 4, 5, 6, 7]" :key="day" :label="day">周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}</el-checkbox></el-checkbox-group></el-form-item></el-col><el-col :span="8"><el-form-item label="开船本地时间" required><el-time-picker v-model="templateForm.departureTime" value-format="HH:mm:ss" /></el-form-item></el-col><el-col :span="8"><el-form-item label="提前截止天数(自然日)" required><div class="template-cutoff-controls"><el-radio-group v-model="templateForm.cutoffDays"><el-radio-button :label="2">2 天</el-radio-button><el-radio-button :label="3">3 天</el-radio-button><el-radio-button :label="4">4 天</el-radio-button></el-radio-group><el-input-number v-model="templateForm.cutoffDays" :min="0" :max="30" controls-position="right" placeholder="自定义" /></div></el-form-item></el-col><el-col :span="8"><el-form-item label="收货截止时刻" required><el-time-picker v-model="templateForm.cutoffLocalTime" value-format="HH:mm:ss" /></el-form-item></el-col><el-col :span="24"><el-collapse><el-collapse-item name="route" title="航线补充(默认从渠道带入)"><el-row :gutter="12"><el-col :span="12"><el-form-item label="起运港" required><el-input v-model="templateForm.departurePortCode" placeholder="如 CNNGB" /></el-form-item></el-col><el-col :span="12"><el-form-item label="目的港" required><el-input v-model="templateForm.destinationPortCode" placeholder="如 USLGB" /></el-form-item></el-col></el-row></el-collapse-item></el-collapse></el-col><el-col :span="12"><el-form-item label="有效期开始" required><el-date-picker v-model="templateForm.effectiveFrom" value-format="YYYY-MM-DD" /></el-form-item></el-col><el-col :span="12"><el-form-item label="有效期结束"><el-date-picker v-model="templateForm.effectiveTo" value-format="YYYY-MM-DD" /></el-form-item></el-col></el-row></section><section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">本次生成范围</h3><el-row :gutter="12"><el-col :span="12"><el-form-item label="本次生成开始" required><el-date-picker v-model="generationFrom" value-format="YYYY-MM-DD" /></el-form-item></el-col><el-col :span="12"><el-form-item label="本次生成结束" required><el-date-picker v-model="generationTo" value-format="YYYY-MM-DD" /></el-form-item></el-col><el-col :span="24"><div class="generation-shortcuts"><span>快捷生成范围:</span><el-button size="small" @click="setGenerationWeeks(4)">未来 4 周</el-button><el-button size="small" @click="setGenerationWeeks(8)">未来 8 周</el-button><el-button size="small" @click="setGenerationWeeks(12)">未来 12 周</el-button></div></el-col></el-row></section></el-form><section v-if="templatePreview.length" class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">生成预览({{ templatePreview.length }} 条)</h3><el-table :data="templatePreview" size="small"><el-table-column prop="etdAt" label="预计开船" /><el-table-column prop="cutoffAt" label="收货截止" /><el-table-column prop="voyageNo" label="航次" /><el-table-column prop="conflictReason" label="冲突 / 结果"><template #default="{ row }">{{ row.conflict ? (row.conflictReason || "有冲突") : "可生成" }}</template></el-table-column></el-table></section></div><template #footer><el-button @click="templateDialogOpen = false">取消</el-button><el-button :loading="templateSaving" type="primary" @click="saveTemplate">保存模板</el-button><el-button :loading="templatePreviewLoading" @click="previewTemplate()">预览生成</el-button><el-button :loading="templateGenerateLoading" type="success" @click="confirmTemplateGeneration">生成船期</el-button></template></AppDialog>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.sailing-calendar-page{min-width:0}.toolbar-filters{display:grid;grid-template-columns:repeat(5,minmax(100px,1fr)) minmax(200px,1.35fr) auto;gap:12px;min-width:0}.filter-select,.filter-keyword{width:100%}.toolbar-actions{display:flex;align-items:center;justify-content:flex-start;gap:8px;white-space:nowrap}.toolbar-actions .el-button{margin:0}.filter-keyword{max-width:none}.calendar-layout{display:grid;grid-template-columns:minmax(0,1fr) 390px;gap:0;align-items:stretch}.calendar-panel{min-width:0;overflow:hidden}.calendar-head{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:70px;box-sizing:border-box;padding:12px 20px;border-bottom:1px solid var(--list-card-border)}.month-switch{display:flex;align-items:center;gap:8px}.month-switch strong{min-width:120px;text-align:center;font-size:20px}.calendar-note{color:var(--cc-text-secondary);font-size:12px}.week-head,.calendar-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr))}.week-head{background:var(--list-card-muted-bg);border-bottom:1px solid var(--list-card-border)}.week-head span{padding:10px;text-align:center;color:var(--cc-text-secondary);font-size:12px}.calendar-cell{display:flex;flex-direction:column;align-items:flex-start;min-height:104px;padding:10px 12px;border:0;border-right:1px solid var(--list-card-border);border-bottom:1px solid var(--list-card-border);background:var(--cc-surface);text-align:left;cursor:pointer;transition:background .15s ease}.calendar-cell:nth-child(7n){border-right:0}.calendar-cell:hover,.calendar-cell.selected{background:var(--workbench-accent-soft)}.calendar-cell.muted{background:var(--workbench-surface-soft);color:var(--workbench-muted)}.day-number{font-weight:600;color:var(--workbench-text)}.calendar-cell.selected .day-number{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:var(--workbench-primary);color:#fff}.cell-records{display:flex;flex-direction:column;gap:5px;max-width:100%;margin-top:8px}.cell-count{color:var(--workbench-primary);font-size:12px;font-weight:600}.cell-count i{display:inline-block;width:7px;height:7px;margin-right:4px;border-radius:50%;background:var(--workbench-primary)}.cell-route{overflow:hidden;color:var(--cc-text-secondary);font-size:11px;text-overflow:ellipsis;white-space:nowrap}.cell-route em{font-style:normal}.cell-warning{color:#d58518;font-size:11px}.detail-panel{min-width:0;min-height:420px;border-left:1px solid var(--list-card-border)}.detail-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;min-height:70px;box-sizing:border-box;padding:12px 16px;border-bottom:1px solid var(--list-card-border)}.detail-header h2{margin:0;font-size:18px;line-height:24px}.detail-header p{margin:4px 0 0;color:var(--cc-text-secondary);font-size:12px;line-height:18px}.empty-detail{padding:48px 20px;color:var(--cc-text-secondary);text-align:center}.sailing-list{display:flex;flex-direction:column;gap:10px;padding:14px}.sailing-card{width:100%;padding:14px;border:1px solid var(--workbench-border);border-radius:var(--workbench-tab-radius);background:var(--workbench-surface);text-align:left;cursor:pointer}.sailing-card:hover,.sailing-card.active{border-color:var(--workbench-primary);background:var(--workbench-accent-soft)}.sailing-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.sailing-card-head strong{font-size:15px;color:var(--workbench-text)}.voyage,.cutoff-line{margin-top:5px;color:var(--cc-text-secondary);font-size:12px}.route-line{margin-top:12px;color:var(--workbench-text);font-size:13px}.route-line b{margin:0 5px;color:var(--workbench-primary)}.volume-line{display:flex;justify-content:space-between;margin-top:12px;padding-top:10px;border-top:1px solid var(--workbench-border);color:var(--cc-text-secondary);font-size:12px}.volume-line b{color:var(--workbench-text)}.detail-actions{display:flex;flex-wrap:wrap;gap:8px;padding:14px;border-top:1px solid var(--workbench-border)}.detail-actions .el-button{margin:0}
|
||
.cutoff-config { display: flex; flex-direction: column; gap: var(--admin-space-3); padding-block: var(--admin-space-2); min-width: 0; }
|
||
.cutoff-mode-group { display: flex; flex-wrap: wrap; gap: var(--admin-space-4); }
|
||
.cutoff-mode-group :deep(.el-radio) { margin-right: 0; }
|
||
.cutoff-rule-row, .cutoff-time-row { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); min-width: 0; }
|
||
.cutoff-control-label { flex: 0 0 auto; color: var(--cc-text-secondary); font-size: var(--admin-font-caption); white-space: nowrap; }
|
||
.cutoff-rule-row > .cutoff-control-label, .cutoff-time-row > .cutoff-control-label { flex-basis: 56px; }
|
||
.cutoff-presets { flex: 0 0 auto; }
|
||
.cutoff-custom-days { display: flex; align-items: center; gap: var(--admin-space-3); margin-left: var(--admin-space-3); }
|
||
.cutoff-days-editor { width: 122px; }
|
||
.cutoff-time-editor { width: 180px; min-width: 0; }
|
||
.sailing-date-editor { width: 280px; max-width: 100%; }
|
||
.cutoff-calculated { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-3); color: var(--cc-text-secondary); font-size: var(--admin-font-caption); }
|
||
.cutoff-calculated strong { color: var(--workbench-text); font-size: var(--admin-font-body); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||
.sailing-pending { color: var(--cc-text-secondary); }
|
||
.template-cutoff-controls{display:flex;align-items:center;gap:8px}.template-cutoff-controls .el-radio-group{display:flex;flex:1}.template-cutoff-controls .el-radio-button{flex:1}.template-cutoff-controls :deep(.el-radio-button__inner){width:100%}.template-cutoff-controls .el-input-number{width:110px}.advice-note{margin:0;color:var(--cc-text-secondary);font-size:var(--admin-font-caption)}.management-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;color:var(--cc-text-secondary);font-size:var(--admin-font-caption)}.management-error{margin-bottom:12px}.generation-shortcuts{display:flex;align-items:center;gap:8px;margin:0 0 var(--admin-space-3);color:var(--cc-text-secondary);font-size:var(--admin-font-caption)}.generation-shortcuts .el-button{margin:0}.form-hint{display:block;margin-top:var(--admin-space-1);color:var(--cc-text-secondary);font-size:var(--admin-font-caption)}.exception-note{margin-bottom:12px;color:var(--cc-text-secondary);font-size:12px}.merge-reference-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.merge-reference-head strong{color:var(--workbench-text);font-size:var(--admin-font-section)}.merge-reference-head span{color:var(--cc-text-secondary);font-size:var(--admin-font-caption)}.merge-reference-sub{margin-top:3px;color:var(--cc-text-secondary);font-size:var(--admin-font-caption)}.sailing-entry-table{table-layout:fixed}.sailing-entry-label{width:116px}.sailing-summary{grid-template-columns:repeat(2,minmax(0,1fr))}@media(max-width:1280px){.toolbar-filters{grid-template-columns:repeat(3,minmax(120px,1fr))}.calendar-layout{grid-template-columns:minmax(0,1fr) 340px}}@media(max-width:900px){.calendar-layout{grid-template-columns:1fr}.detail-panel{min-height:0;border-left:0;border-top:1px solid var(--list-card-border)}.calendar-cell{min-height:96px}}@media(max-width:560px){.toolbar-filters{grid-template-columns:1fr 1fr}.toolbar-actions{align-items:stretch;flex-wrap:wrap}.toolbar-actions>*{flex:1}.filter-select,.filter-keyword{width:100%}.calendar-cell{min-height:80px;padding:7px 5px}.cell-route,.cutoff-line{display:none}}
|
||
</style>
|