Files
admin-web/src/modules/sailing-calendar/pages/SailingCalendarView.vue
T

622 lines
66 KiB
Vue
Raw Normal View History

<script setup lang="ts">
2026-10-20 15:48:23 +08:00
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
2026-10-20 23:34:11 +08:00
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'
2026-09-30 14:01:58 +08:00
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')}`
2026-09-27 23:41:28 +08:00
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>
2026-10-20 23:34:11 +08:00
<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">
2026-09-30 14:01:58 +08:00
<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>
2026-09-30 14:01:58 +08:00
</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>
2026-09-30 14:01:58 +08:00
</div>
</template>
2026-10-20 23:34:11 +08:00
</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">
2026-10-20 23:34:11 +08:00
<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>
2026-10-20 23:34:11 +08:00
<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>
2026-10-20 23:34:11 +08:00
<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">
2026-10-20 23:34:11 +08:00
<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>
2026-10-20 23:34:11 +08:00
<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>
2026-10-20 23:34:11 +08:00
</table></div>
</section>
2026-10-20 15:48:27 +08:00
</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) }}
2026-10-20 23:34:11 +08:00
<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="æœ
</AdminPage>
</template>
<style scoped>
2026-10-20 23:34:11 +08:00
.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>