436 lines
57 KiB
Vue
436 lines
57 KiB
Vue
<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>
|