diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 6faccef2..b01f1c90 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -51,6 +51,9 @@ const volumeDetail = ref(null) const adviceOpen = ref(false) const adviceLoading = ref(false) const bookingAdvice = ref(null) +const cutoffMode = ref<'rule' | 'custom'>('rule') +const cutoffDays = ref(3) +const cutoffLocalTime = ref('18:00:00') const form = reactive(blankForm()) const managementOpen = ref(false) const managementTab = ref<'channels' | 'templates'>('channels') @@ -68,9 +71,14 @@ const templateGenerateLoading = ref(false) const templatePreview = ref>([]) const editingTemplateId = ref('') const templateForm = reactive({ 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)) { generationFrom.value = from || formatDate(today); 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³` } @@ -83,10 +91,15 @@ function blankForm(): SailingSaveRequest { } function cutoffFor(etdAt: string) { const value = new Date(etdAt) - value.setDate(value.getDate() - 3) - value.setHours(18, 0, 0, 0) - return `${formatDate(value)}T18:00:00` + 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(() => data.value?.filterOptions || { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] }) const recordsByDate = computed(() => { @@ -214,8 +227,10 @@ async function saveChannel() { } function openTemplateCreate() { editingTemplateId.value = '' - Object.assign(templateForm, { templateName: '', channelId: channels.value.find((item) => item.status === 'ACTIVE')?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '', departurePortCode: '', destinationPortCode: '', remark: '', expectedVersion: undefined }) + 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: '', destinationPortCode: '', remark: '', expectedVersion: undefined }) templatePreview.value = [] + resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } function openTemplateEdit(row: unknown) { @@ -223,6 +238,7 @@ function openTemplateEdit(row: unknown) { 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 = [] + resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } function templatePayload() { return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b) } } @@ -240,11 +256,12 @@ async function saveTemplate() { await openManagement('templates') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '周模板保存失败').message) } finally { templateSaving.value = false } } -function generationRange() { const from = templateForm.effectiveFrom; const to = templateForm.effectiveTo || `${month.value}-28`; return { from, to } } +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 @@ -252,12 +269,12 @@ async function previewTemplate(row?: unknown) { } async function generateTemplate(row: unknown) { const templateRow = row as SailingTemplateResource - try { await ElMessageBox.confirm(`确认按模板“${templateRow.templateNo}”生成选定有效期内的船期?重复日期会由服务端跳过。`, '批量生成船期', { type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' }); templateGenerateLoading.value = true; const result = await generateSailingTemplate(templateRow.id, { from: templateRow.effectiveFrom, to: templateRow.effectiveTo || `${month.value}-28` }); ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`); await load(); await openManagement('templates') } catch (cause) { if (cause !== 'cancel') ElMessage.error(getApiErrorDetails(cause, '批量生成失败').message) } finally { templateGenerateLoading.value = false } + try { openTemplateEdit(templateRow); if (!generationFrom.value || !generationTo.value || generationFrom.value > generationTo.value) { ElMessage.error('请填写有效的本次生成起止日期'); return } await ElMessageBox.confirm(`确认按模板“${templateRow.templateNo}”生成 ${generationFrom.value} 至 ${generationTo.value} 的船期?重复日期会由服务端跳过。`, '批量生成船期', { type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' }); templateGenerateLoading.value = true; const result = await generateSailingTemplate(templateRow.id, generationRange()); ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`); await load(); await openManagement('templates') } catch (cause) { if (cause !== 'cancel') ElMessage.error(getApiErrorDetails(cause, '批量生成失败').message) } finally { templateGenerateLoading.value = false } } function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.map((day: unknown) => ['一', '二', '三', '四', '五', '六', '日'][Number(day) - 1]).join('、') : '—' } -function openCreate() { dialogMode.value = 'create'; Object.assign(form, blankForm()); dialogOpen.value = true } -function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; Object.assign(form, { templateId: null, 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: null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: 'MANUAL', plannedCapacityCbm: null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } -function onEtdChanged() { if (dialogMode.value === 'create') form.cutoffAt = cutoffFor(form.etdAt) } +function openCreate() { dialogMode.value = 'create'; cutoffMode.value = 'rule'; cutoffDays.value = 3; cutoffLocalTime.value = '18:00:00'; Object.assign(form, blankForm()); dialogOpen.value = true } +function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: null, 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: null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: 'MANUAL', 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', '渠道编码'], ['channelName', '渠道名称'], ['vesselName', '船名'], ['voyageNo', '航次'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } return true } async function save() { if (!validateForm()) return @@ -285,7 +302,11 @@ async function copySelected() { const record = selectedSailing.value if (!record || !canEdit.value) return const etd = new Date(record.etdAt); etd.setDate(etd.getDate() + 7) - try { await copySailing(record.id, { etdAt: `${formatDate(etd)}T${record.etdAt.slice(11, 19)}`, cutoffAt: cutoffFor(`${formatDate(etd)}T${record.etdAt.slice(11, 19)}`) }); ElMessage.success('已复制为下一班'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) } + 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 { await copySailing(record.id, { etdAt, cutoffAt }); ElMessage.success('已复制为下一班'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) } } async function openVolumeDetail() { if (!selectedSailing.value) return @@ -321,7 +342,8 @@ onMounted(() => void load()) 重置 渠道与周模板 - 新增船期 + 按周批量生成 + 新增单个船次
{{ card.label }}{{ card.value }}
@@ -335,17 +357,17 @@ onMounted(() => void load()) - + 按开船前 N 天自定义时间
提前 2 天提前 3 天提前 4 天
建议截止:{{ dateTime(form.cutoffAt) }}(按开船本地日期倒推自然日)
停用渠道仍保留历史船期,但不能新建模板或生成未来船期。新增渠道
按周几生成前先预览,生成操作使用服务端幂等键。新增周模板
- 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}

生成预览({{ templatePreview.length }} 条)

+ 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)