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

622 lines
66 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import 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>