diff --git a/src/modules/sailing-calendar/api/platformSailingCalendar.ts b/src/modules/sailing-calendar/api/platformSailingCalendar.ts index 5b92c1f2..c120a55f 100644 --- a/src/modules/sailing-calendar/api/platformSailingCalendar.ts +++ b/src/modules/sailing-calendar/api/platformSailingCalendar.ts @@ -16,6 +16,11 @@ function entry(raw: SailingCalendarEntry): SailingCalendarEntry { warehouseId: String(raw.warehouseId ?? ''), channelCode: String(raw.channelCode ?? ''), channelName: String(raw.channelName ?? raw.channelCode ?? ''), + templateId: raw.templateId == null ? null : String(raw.templateId), + channelId: raw.channelId == null ? null : String(raw.channelId), + etaAt: raw.etaAt ?? null, + sourceType: raw.sourceType ?? null, + plannedCapacityCbm: raw.plannedCapacityCbm == null ? null : number(raw.plannedCapacityCbm), volumeSummary: summary(raw.volumeSummary), version: number(raw.version), } diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 6233aea3..38ab71a3 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -62,6 +62,7 @@ const managementError = ref(null) const channels = ref([]) const channelDialogOpen = ref(false) const channelSaving = ref(false) +const editingChannelId = ref('') const channelForm = reactive({ channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY', mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '' }) const templates = ref([]) const templateDialogOpen = ref(false) @@ -169,6 +170,7 @@ const summaryCards = computed(() => { }) 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() { @@ -214,16 +216,19 @@ async function toggleChannel(row: unknown) { ElMessage.success(channelRow.status === 'ACTIVE' ? '渠道已启用' : '渠道已停用') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道状态更新失败').message) } } -function openChannelCreate() { Object.assign(channelForm, { channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY' as SailingChannelSourceType, mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '', expectedVersion: undefined }); channelDialogOpen.value = true } +function openChannelCreate() { editingChannelId.value = ''; Object.assign(channelForm, { channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY' as SailingChannelSourceType, mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '', expectedVersion: undefined, status: undefined }); channelDialogOpen.value = true } +function openChannelEdit(row: unknown) { const channelRow = row as SailingChannelResource; editingChannelId.value = channelRow.id; Object.assign(channelForm, { channelCode: channelRow.channelCode, channelName: channelRow.channelName, sourceType: channelRow.sourceType, mappedChannelCode: channelRow.mappedChannelCode || '', defaultDeparturePortCode: channelRow.defaultDeparturePortCode || '', defaultDestinationPortCode: channelRow.defaultDestinationPortCode || '', defaultCutoffDays: channelRow.defaultCutoffDays, remark: channelRow.remark || '', expectedVersion: channelRow.version, status: channelRow.status }); channelDialogOpen.value = true } async function saveChannel() { if (!channelForm.channelCode.trim() || !channelForm.channelName.trim() || channelForm.defaultCutoffDays < 0) { ElMessage.error('请填写渠道编码、名称和有效的提前截止天数'); return } channelSaving.value = true try { - const response = await createSailingChannel(channelForm) - channels.value = [...channels.value, response] + const response = editingChannelId.value ? await updateSailingChannel(editingChannelId.value, channelForm) : await createSailingChannel(channelForm) + if (editingChannelId.value) channels.value = channels.value.map((item) => item.id === response.id ? response : item) + else channels.value = [...channels.value, response] channelDialogOpen.value = false - ElMessage.success('渠道已创建') - } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道创建失败').message) } finally { channelSaving.value = false } + ElMessage.success(editingChannelId.value ? '渠道已更新' : '渠道已创建') + editingChannelId.value = '' + } catch (cause) { ElMessage.error(getApiErrorDetails(cause, editingChannelId.value ? '渠道更新失败' : '渠道创建失败').message) } finally { channelSaving.value = false } } function openTemplateCreate() { editingTemplateId.value = '' @@ -233,12 +238,12 @@ function openTemplateCreate() { resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } -function openTemplateEdit(row: unknown) { +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 = [] - resetGenerationRange(templateForm.effectiveFrom) + if (!options.preserveGenerationRange) resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } function templatePayload() { return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b) } } @@ -269,11 +274,25 @@ async function previewTemplate(row?: unknown) { } async function generateTemplate(row: unknown) { const templateRow = row as SailingTemplateResource - 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 } + 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 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 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: 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', '渠道编码'], ['channelName', '渠道名称'], ['vesselName', '船名'], ['voyageNo', '航次'], ['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() { @@ -300,7 +319,7 @@ async function cancelSelected() { } async function copySelected() { const record = selectedSailing.value - if (!record || !canEdit.value) return + 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() @@ -363,15 +382,15 @@ onMounted(() => void load())
{{ day }}
- + 按开船前 N 天自定义时间
提前 2 天提前 3 天提前 4 天
建议截止:{{ dateTime(form.cutoffAt) }}(按开船本地日期倒推自然日)
-
停用渠道仍保留历史船期,但不能新建模板或生成未来船期。新增渠道
按周几生成前先预览,生成操作使用服务端幂等键。新增周模板
- - 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

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

+
停用渠道仍保留历史船期,但不能新建模板或生成未来船期。新增渠道
按周几生成前先预览,生成操作使用服务端幂等键。新增周模板
+ + 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

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

diff --git a/src/modules/sailing-calendar/types.ts b/src/modules/sailing-calendar/types.ts index a47a1fda..0e582108 100644 --- a/src/modules/sailing-calendar/types.ts +++ b/src/modules/sailing-calendar/types.ts @@ -36,6 +36,11 @@ export interface SailingCalendarEntry { status: SailingStatus volumeSummary: SailingVolumeSummary version: number + templateId?: string | null + channelId?: string | null + etaAt?: string | null + sourceType?: string | null + plannedCapacityCbm?: number | null remark?: string | null }