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

436 lines
57 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, 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 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()
const today = new Date()
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 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 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 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() {
loading.value = true
error.value = null
try {
const result = await listSailingCalendar({ month: month.value, 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 })
data.value = result
const todayKey = formatDate(today)
selectedDate.value = month.value === formatMonth(today) ? todayKey : (selectedDate.value && selectedDate.value.startsWith(month.value) ? selectedDate.value : result.records[0]?.etdAt.slice(0, 10) || `${month.value}-01`)
if (selectedSailing.value) selectedSailing.value = result.records.find((record) => record.id === selectedSailing.value?.id) || null
} catch (cause) {
error.value = getApiErrorDetails(cause, '船期日历加载失败')
} finally {
loading.value = false
}
}
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() { month.value = formatMonth(today); selectedDate.value = formatDate(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 = '待订舱确认'
}
function onTemplateWarehouseChanged(id: string) { applyWarehouse(id, templateForm) }
function onFormWarehouseChanged(id: string) { applyWarehouse(id, form) }
async function openTemplateCreate() {
await ensureWarehouseOptions()
editingTemplateId.value = ''
const activeChannel = channels.value.find((item) => item.status === 'ACTIVE')
Object.assign(templateForm, { templateName: '', channelId: activeChannel?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: activeChannel?.defaultCutoffDays ?? 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '待订舱确认', departurePortCode: activeChannel?.defaultDeparturePortCode || '', destinationPortCode: activeChannel?.defaultDestinationPortCode || '', remark: '', expectedVersion: undefined })
templatePreview.value = []
resetGenerationRange(templateForm.effectiveFrom)
templateDialogOpen.value = true
}
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)
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} 的船期?重复日期会由服务端跳过。`, '批量生成船期', { 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() {
dialogMode.value = 'create'
cutoffMode.value = 'rule'
cutoffDays.value = 3
cutoffLocalTime.value = '18:00:00'
Object.assign(form, blankForm(), { expectedVersion: undefined })
routeExpanded.value = []
dialogOpen.value = true
formOptionsLoading.value = true
try {
const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()])
channels.value = rows
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败,请关闭后重试').message) }
finally { formOptionsLoading.value = false }
}
async function openEdit(record: SailingCalendarEntry) { try { const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()]); channels.value = rows } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败').message); return } 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
saving.value = true
try {
if (dialogMode.value === 'create') await createSailing({ ...form, expectedVersion: undefined })
else if (selectedSailing.value) await updateSailing(selectedSailing.value.id, 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 || '待订舱确认'}”?已产生的归班历史会保留。`, '取消船期', { 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() {
if (!selectedSailing.value) return
detailOpen.value = true; detailLoading.value = true; volumeDetail.value = null
try { volumeDetail.value = await getSailingVolumeDetail(selectedSailing.value.id) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '货量明细加载失败').message) } finally { detailLoading.value = false }
}
async function openHistory() {
if (!selectedSailing.value) return
historyOpen.value = true; historyLoading.value = true; histories.value = []
try { histories.value = (await getSailing(selectedSailing.value.id)).histories || [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '变更历史加载失败').message) } finally { historyLoading.value = false }
}
async function openBookingAdvice() {
if (!selectedSailing.value) return
adviceOpen.value = true; adviceLoading.value = true; bookingAdvice.value = null
try { bookingAdvice.value = await getSailingBookingAdvice(selectedSailing.value.id) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '订柜建议加载失败').message) } finally { adviceLoading.value = false }
}
watch(month, () => void load())
watch(() => [query.warehouseId, query.channelCode, query.departurePortCode, query.destinationPortCode, query.status], () => void load())
onMounted(() => void load())
</script>
<template>
<AdminPage as="div" class="sailing-calendar-page admin-density">
<div class="calendar-toolbar surface-panel">
<div class="toolbar-content">
<div class="toolbar-filters">
<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" />
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button v-if="canManageTemplates" @click="openManagement('channels')">渠道与周模板</el-button>
<el-button v-if="canManageTemplates" plain @click="openManagement('templates')">按周批量生成</el-button>
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增单个船次</el-button>
</div>
</div>
</div>
<LoadErrorState v-if="error" title="船期日历加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-else class="calendar-layout">
<section class="calendar-panel surface-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 surface-panel"><div class="detail-header"><div><h2>{{ selectedDate }}</h2><p>当天共有 {{ selectedDateRecords.length }} 个船期</p></div><el-button v-if="canCreate" type="primary" plain size="small" @click="openCreate"><Plus :size="15" />新增</el-button></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>
<el-dialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增船期' : '编辑船期'" width="760px" destroy-on-close><el-form v-loading="formOptionsLoading" label-position="top" class="sailing-form"><el-row :gutter="16"><el-col :span="12"><el-form-item label="收货仓" required><el-select v-model="form.warehouseId" filterable style="width:100%" 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></el-form-item></el-col><el-col :span="12"><el-form-item label="渠道" required><el-select v-model="form.channelCode" filterable clearable style="width:100%" 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><span class="form-hint">选择已有渠道,自动带入排船默认资料。</span></el-form-item></el-col><el-col :span="12"><el-form-item label="船名 / 航次"><div class="form-readonly-value">{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}</div><span class="form-hint">未确认船名、航次时可先排船,显示为待订舱确认。</span></el-form-item></el-col><el-col :span="24"><el-collapse v-model="routeExpanded"><el-collapse-item name="route" :title="`航线资料:${form.departurePortCode || '待补起运港'} → ${form.destinationPortCode || '待补目的港'}`"><el-row :gutter="16"><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></el-col><el-col :span="12"><el-form-item label="预计开船" required><el-date-picker v-model="form.etdAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" @change="onEtdChanged" /></el-form-item></el-col><el-col :span="12"><el-form-item label="收货截止规则" required><div class="cutoff-config"><el-radio-group v-model="cutoffMode" class="cutoff-mode-group" @change="onCutoffModeChanged"><el-radio-button label="rule">按开船前 N 天</el-radio-button><el-radio-button label="custom">自定义时间</el-radio-button></el-radio-group><div v-if="cutoffMode === 'rule'" class="cutoff-rule-panel"><div class="cutoff-rule-row"><span class="cutoff-control-label">提前天数</span><el-radio-group v-model="cutoffDays" class="cutoff-presets" @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><el-input-number v-model="cutoffDays" class="cutoff-days-input" :min="0" :max="30" controls-position="right" placeholder="请输入提前天数" @change="onCutoffRuleChanged" /></div><div class="cutoff-time-row"><span class="cutoff-control-label">截止时刻</span><el-time-picker v-model="cutoffLocalTime" value-format="HH:mm:ss" class="cutoff-time-picker" @change="onCutoffRuleChanged" /></div><div class="cutoff-calculated"><span>建议截止</span><strong>{{ dateTime(form.cutoffAt) }}</strong><small>按开船本地日期倒推自然日</small></div></div><el-date-picker v-else v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" @change="onCustomCutoffChanged" /></div></el-form-item></el-col></el-row><el-alert title="选择收货仓、渠道并设置开船和截止时间;航线资料优先使用渠道默认值,缺失时展开补充。" type="info" :closable="false" /></el-form><template #footer><el-button @click="dialogOpen = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="formOptionsLoading" @click="save">保存</el-button></template></el-dialog>
<el-dialog class="admin-density-dialog" v-model="detailOpen" title="货量明细(只读)" width="760px"><div v-loading="detailLoading"><template v-if="volumeDetail"><div class="detail-summary"><span>已收实测 <b>{{ decimal(volumeDetail.summary.actualReceivedCbm) }}</b></span><span>截止前参考 <b>{{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}</b></span><span>晚到 / 风险 <b>{{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}</b></span><span>预计参考 <b>{{ decimal(volumeDetail.summary.catchableForecastCbm) }}</b></span><span>待确认 <b>{{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}</b></span></div><el-alert title="本页面只展示现有审核收货和已放舱订舱数据,不会自动归班或改绑货物。预计量没有有效体积来源时显示 0,仅代表暂无可核验数据。" type="info" :closable="false" class="detail-readonly-note" /><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></template></div></el-dialog>
<el-dialog class="admin-density-dialog" v-model="historyOpen" title="船期变更历史" width="760px"><div v-loading="historyLoading"><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></div></el-dialog><el-dialog class="admin-density-dialog" v-model="adviceOpen" title="订柜建议(只读初算)" width="720px"><div v-loading="adviceLoading"><template v-if="bookingAdvice"><div class="advice-highlight"><span>已收实测 <b>{{ decimal(bookingAdvice.actualReceivedCbm || 0) }}</b></span><span>截止前参考 <b>{{ decimal(bookingAdvice.catchableTotalCbm) }}</b></span><span>晚到 / 风险 <b>{{ decimal(bookingAdvice.lateReceivedCbm || 0) }}</b></span><span>已订柜 <b>{{ bookingAdvice.bookedContainerCount }} 柜</b></span><span>建议补柜 <b>{{ bookingAdvice.pendingContainerCount }} 柜</b></span></div><p class="advice-note">以上为现有数据的只读初算,不会自动归班、合船或创建订舱;最终以智能排柜和人工确认结果为准。</p><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><div class="merge-reference"><div class="merge-reference-head"><strong>相邻船期合船参考</strong><span>只做展示,不自动合并</span></div><div class="merge-reference-current"><span>本船参考量 <b>{{ decimal(bookingAdvice.catchableTotalCbm) }}</b></span><span>规划容量 <b>{{ decimal(bookingAdvice.planningCapacityCbm || 0) }}</b></span><span>剩余容量 <b>{{ decimal(currentRemainingCapacity) }}</b></span></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><p class="advice-note merge-reference-note">仅展示同仓、同渠道、同起运港和目的港的相邻船期;实际是否合船仍回到智能排柜和人工确认。</p></div></template></div></el-dialog>
<el-dialog class="admin-density-dialog" v-model="managementOpen" title="渠道与周模板管理" width="1080px"><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"><el-tab-pane label="渠道" name="channels"><div class="management-toolbar"><span>查看已有船期渠道及默认收货规则。</span></div><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" /><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></el-tab-pane><el-tab-pane label="每周模板" name="templates"><div class="management-toolbar"><span>设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。</span><el-button v-if="canManageTemplates" type="primary" size="small" @click="openTemplateCreate"><Plus :size="14" />新增周模板</el-button></div><el-table :data="templates" size="small" empty-text="暂无周模板"><el-table-column prop="templateNo" label="模板编号" width="140" /><el-table-column prop="channelName" label="渠道" min-width="130" /><el-table-column prop="warehouseName" label="收货仓" min-width="140" /><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-column v-if="canManageTemplates" label="操作" width="220" fixed="right"><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></el-tab-pane></el-tabs></div></el-dialog>
<el-dialog class="admin-density-dialog" v-model="templateDialogOpen" :title="editingTemplateId ? '编辑周模板' : '新增周模板'" width="680px"><el-form label-position="top"><el-row :gutter="16"><el-col :span="12"><el-form-item label="渠道" required><el-select v-model="templateForm.channelId" filterable style="width:100%" @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="12"><el-form-item label="收货仓" required><el-select v-model="templateForm.warehouseId" filterable style="width:100%" 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="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="12"><el-form-item label="开船本地时间" required><el-time-picker v-model="templateForm.departureTime" value-format="HH:mm:ss" style="width:100%" /></el-form-item></el-col><el-col :span="12"><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="12"><el-form-item label="收货截止时刻" required><el-time-picker v-model="templateForm.cutoffLocalTime" value-format="HH:mm:ss" style="width:100%" /></el-form-item></el-col><el-col :span="24"><el-collapse><el-collapse-item name="route" title="航线补充(默认从渠道带入)"><el-row :gutter="16"><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" style="width:100%" /></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" style="width:100%" /></el-form-item></el-col><el-col :span="12"><el-form-item label="本次生成开始" required><el-date-picker v-model="generationFrom" value-format="YYYY-MM-DD" style="width:100%" /></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" style="width:100%" /></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><el-alert title="模板只保存每周规律;本次生成区间默认未来 8 周,先预览再确认生成,重复日期由服务端跳过。仓库和渠道直接选择已有资料;港口优先从渠道带入,船名未确认时可先排船。" type="info" :closable="false" /></el-form><div v-if="templatePreview.length" class="preview-table"><h4>生成预览({{ templatePreview.length }} 条)</h4><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></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></el-dialog>
</AdminPage>
</template>
<style scoped>
.sailing-calendar-page{min-width:0}.calendar-toolbar{display:grid;align-items:center;padding:12px 16px;margin-bottom:12px}.toolbar-title{display:flex;align-items:flex-start;gap:12px;color:var(--cc-primary)}.toolbar-title h1{margin:0;color:var(--cc-text);font-size:21px;line-height:1.3}.toolbar-title p{max-width:180px;margin:6px 0 0;color:var(--cc-text-secondary);font-size:12px;line-height:1.5}.toolbar-content{display:flex;flex-direction:column;gap:8px;min-width:0}.toolbar-filters{display:grid;grid-template-columns:repeat(5,minmax(100px,1fr)) minmax(200px,1.35fr);gap:8px;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:12px;align-items:start}.calendar-panel{overflow:hidden}.calendar-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--cc-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:#f7f8fc;border-bottom:1px solid var(--cc-border)}.week-head span{padding:10px;text-align:center;color:var(--cc-text-secondary);font-size:12px}.calendar-cell{min-height:116px;padding:10px 9px;border:0;border-right:1px solid var(--cc-border);border-bottom:1px solid var(--cc-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:#f0f5ff}.calendar-cell.muted{background:#fafafa;color:#b3bac8}.day-number{font-weight:600;color:var(--cc-text)}.calendar-cell.selected .day-number{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:var(--cc-primary);color:#fff}.cell-records{display:flex;flex-direction:column;gap:5px;margin-top:10px}.cell-count{color:#3b6eea;font-size:12px;font-weight:600}.cell-count i{display:inline-block;width:7px;height:7px;margin-right:4px;border-radius:50%;background:#5b84ef}.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-height:420px}.detail-header{display:flex;align-items:flex-start;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--cc-border)}.detail-header h2{margin:0;font-size:20px}.detail-header p{margin:5px 0 0;color:var(--cc-text-secondary);font-size:12px}.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(--cc-border);border-radius:10px;background:#fff;text-align:left;cursor:pointer}.sailing-card:hover,.sailing-card.active{border-color:var(--cc-primary);box-shadow:0 3px 12px rgba(65,95,180,.12)}.sailing-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.sailing-card-head strong{font-size:15px;color:var(--cc-text)}.voyage,.cutoff-line{margin-top:5px;color:var(--cc-text-secondary);font-size:12px}.route-line{margin-top:12px;color:var(--cc-text);font-size:13px}.route-line b{margin:0 5px;color:var(--cc-primary)}.volume-line{display:flex;justify-content:space-between;margin-top:12px;padding-top:10px;border-top:1px solid #edf0f5;color:var(--cc-text-secondary);font-size:12px}.volume-line b{color:var(--cc-text)}.detail-actions{display:flex;flex-wrap:wrap;gap:8px;padding:14px;border-top:1px solid var(--cc-border)}.detail-actions .el-button{margin:0}.sailing-form{padding-top:4px}.sailing-form .el-collapse{width:100%;margin-bottom:12px}.form-readonly-value{width:100%;line-height:32px}.cutoff-config{display:flex;flex-direction:column;gap:10px;width:100%}.cutoff-mode-group{display:flex;width:100%}.cutoff-mode-group .el-radio-button{flex:1}.cutoff-mode-group .el-radio-button__inner{display:block;width:100%;padding:9px 10px}.cutoff-rule-panel{display:flex;flex-direction:column;gap:10px;padding:12px;border:1px solid var(--cc-border);border-radius:8px;background:#fafbfe}.cutoff-rule-row,.cutoff-time-row{display:flex;align-items:center;gap:10px;min-width:0}.cutoff-control-label{flex:0 0 64px;color:var(--cc-text-secondary);font-size:12px}.cutoff-presets{display:flex;flex:1;min-width:0}.cutoff-presets .el-radio-button{flex:1;min-width:0}.cutoff-presets .el-radio-button__inner{display:block;width:100%;padding:8px 4px}.cutoff-days-input{width:92px;flex:0 0 92px}.cutoff-time-picker{width:auto!important;flex:1}.cutoff-calculated{display:flex;align-items:baseline;gap:8px;padding:9px 10px;border-radius:6px;background:#f0f4fc;color:var(--cc-text-secondary);font-size:12px}.cutoff-calculated strong{color:var(--cc-text);font-size:13px;font-weight:600}.cutoff-calculated small{color:var(--cc-text-secondary);font-size:11px}.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 .el-radio-button__inner{width:100%;padding-left:8px;padding-right:8px}.template-cutoff-controls .el-input-number{width:110px}.detail-summary,.advice-highlight{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:14px}.detail-summary span,.advice-highlight span{padding:10px 12px;border-radius:8px;background:#f5f7fb;color:var(--cc-text-secondary);font-size:12px}.detail-summary b,.advice-highlight b{display:block;margin-top:4px;color:var(--cc-text);font-size:16px}.advice-note{margin:0 0 12px;color:var(--cc-text-secondary);font-size:12px}.management-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;color:var(--cc-text-secondary);font-size:12px}.management-error{margin-bottom:12px}.generation-shortcuts{display:flex;align-items:center;gap:8px;margin:-2px 0 10px;color:var(--cc-text-secondary);font-size:12px}.generation-shortcuts .el-button{margin:0}.preview-table{margin-top:14px}.preview-table h4{margin:0 0 8px;color:var(--cc-text);font-size:14px}.form-hint{display:block;margin-top:6px;color:var(--cc-text-secondary);font-size:12px}.exception-note{margin-bottom:12px;color:var(--cc-text-secondary);font-size:12px}.merge-reference{margin-top:18px;padding-top:16px;border-top:1px solid var(--cc-border)}.merge-reference-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.merge-reference-head strong{color:var(--cc-text);font-size:14px}.merge-reference-head span{color:var(--cc-text-secondary);font-size:12px}.merge-reference-current{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}.merge-reference-current span{padding:8px 10px;border-radius:8px;background:#f5f7fb;color:var(--cc-text-secondary);font-size:12px}.merge-reference-current b{margin-left:3px;color:var(--cc-text)}.merge-reference-sub{margin-top:3px;color:var(--cc-text-secondary);font-size:11px}.merge-reference-note{margin-top:10px}@media(max-width:1280px){.toolbar-filters{grid-template-columns:repeat(3,minmax(120px,1fr))}.toolbar-title p{max-width:none}.calendar-layout{grid-template-columns:minmax(0,1fr) 340px}}@media(max-width:900px){.calendar-layout{grid-template-columns:1fr}.detail-panel{min-height:0}.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>