From a16beee544610e3d82f5b3e41b8f8f2d3e76d978 Mon Sep 17 00:00:00 2001 From: hwj Date: Thu, 9 Apr 2026 15:47:24 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=B7=BB=E5=8A=A0=E7=94=98?= =?UTF-8?q?=E7=89=B9=E5=9B=BE=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 8 + .../components/TaskScheduleDialog.vue | 412 +++++++++++++++++- 3 files changed, 415 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index f69881ab..0fc159af 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "cropperjs": "^1.6.1", "crypto-js": "^4.2.0", "dayjs": "^1.11.10", + "dhtmlx-gantt": "^9.1.3", "diagram-js": "^12.8.0", "driver.js": "^1.3.1", "echarts": "^5.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfdd55d3..a7bb18e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: dayjs: specifier: ^1.11.10 version: 1.11.19 + dhtmlx-gantt: + specifier: ^9.1.3 + version: 9.1.3 diagram-js: specifier: ^12.8.0 version: 12.8.1 @@ -3164,6 +3167,9 @@ packages: engines: {node: '>=0.10'} hasBin: true + dhtmlx-gantt@9.1.3: + resolution: {integrity: sha512-3FjvcsGVLFgpZgQKjzKnw1vZh2dIKwLt1Zm6G9Oo/bW2Ogm6/PeQ2cNHO033MWicuxJZn2mpLmg6yssUK3RAiQ==} + diagram-js-direct-editing@3.2.0: resolution: {integrity: sha512-+pyxeQGBSdLiZX0/tmmsm2qZSvm9YtVzod5W3RMHSTR7VrkUMD6E7EX/W9JQv3ebxO7oIdqFmytmNDDpSHnYEw==} peerDependencies: @@ -9388,6 +9394,8 @@ snapshots: detect-libc@1.0.3: optional: true + dhtmlx-gantt@9.1.3: {} + diagram-js-direct-editing@3.2.0(diagram-js@14.11.3): dependencies: diagram-js: 14.11.3 diff --git a/src/views/mes/tasksummary/components/TaskScheduleDialog.vue b/src/views/mes/tasksummary/components/TaskScheduleDialog.vue index 64645790..12dc58a1 100644 --- a/src/views/mes/tasksummary/components/TaskScheduleDialog.vue +++ b/src/views/mes/tasksummary/components/TaskScheduleDialog.vue @@ -1,5 +1,5 @@ @@ -160,6 +200,9 @@ import { DICT_TYPE } from '@/utils/dict' import { TaskApi } from '@/api/mes/task' import ItemNeedIndex from '@/views/mes/bom/ItemNeedIndex.vue' import { dateFormatter2 } from '@/utils/formatTime' +import dayjs from 'dayjs' +import { gantt } from 'dhtmlx-gantt' +import 'dhtmlx-gantt/codebase/dhtmlxgantt.css' defineOptions({ name: 'TaskScheduleDialog' }) @@ -171,9 +214,14 @@ const dialogVisible = ref(false) const taskLoading = ref(false) const detailLoading = ref(false) const submitLoading = ref(false) +const previewVisible = ref(false) const itemNeedRef = ref() const taskTableRef = ref() const detailTableRef = ref() +const ganttContainerRef = ref() +const previewScheduleList = ref([]) +const activePreviewDevice = ref() +const ganttEventIds = ref([]) const scheduleRuleOptions = [ { label: '订单优先级', value: 2 }, @@ -254,7 +302,7 @@ const loadDetailList = async (taskId?: number) => { }) const list = (data?.list ?? []).map((item: any) => ({ ...item, - _parentTaskOrderPriority: currentTask.value?.orderPriority, + _parentTaskOrderPriority: currentTask.value?.isUrgent, _parentTaskDeliveryDate: currentTask.value?.deliveryDate })) @@ -430,6 +478,263 @@ const openDetailCreatePlan = (_row: any) => { message.info('请在任务单汇总明细列表中使用“新增计划”功能') } +const getGlobalDateRange = (scheduleList: any[]) => { + const allPlans = scheduleList.flatMap((item: any) => item?.plans ?? []) + const starts = allPlans.map((item: any) => dayjs(item?.planStartTimeStr).valueOf()).filter((item: number) => Number.isFinite(item)) + const ends = allPlans.map((item: any) => dayjs(item?.planEndTimeStr).valueOf()).filter((item: number) => Number.isFinite(item)) + if (!starts.length || !ends.length) { + const now = Date.now() + return { + start: now, + end: now + 7 * 24 * 60 * 60 * 1000 + } + } + return { + start: Math.min(...starts), + end: Math.max(...ends) + } +} + +const formatGanttDate = (value: unknown) => { + const date = dayjs(value) + if (!date.isValid()) return undefined + return date.format('YYYY-MM-DD HH:mm') +} + +const buildPreviewGanttData = (scheduleList: any[]) => { + const tasks: any[] = [] + const links: any[] = [] + let linkIndex = 1 + + scheduleList.forEach((device: any) => { + const deviceId = `device-${device.deviceId}` + const plans = (device?.plans ?? []).map((plan: any) => ({ + ...plan, + _start: dayjs(plan?.planStartTimeStr), + _end: dayjs(plan?.planEndTimeStr) + })) + const validPlans = plans + .filter((plan: any) => plan._start.isValid() && plan._end.isValid()) + .sort((a: any, b: any) => a._start.valueOf() - b._start.valueOf()) + const firstPlan = validPlans[0] + const lastPlan = validPlans[validPlans.length - 1] + const parentDuration = + firstPlan && lastPlan ? Math.max(lastPlan._end.endOf('day').diff(firstPlan._start.startOf('day'), 'day') + 1, 1) : 1 + + tasks.push({ + id: deviceId, + text: `${device.deviceName ?? '-'}(产能:${device.ratedCapacity ?? '-'})`, + start_date: formatGanttDate(firstPlan?._start), + end_date: formatGanttDate(lastPlan?._end), + duration: parentDuration, + parent: 0, + progress: 0, + open: true, + deviceName: device.deviceName ?? '-', + _deviceData: device + }) + + let previousPlanTaskId: string | null = null + validPlans.forEach((plan: any, index: number) => { + const startDate = formatGanttDate(plan.planStartTimeStr) + const endDate = formatGanttDate(plan.planEndTimeStr) + const duration = Number(plan.scheduleDays) > 0 ? Number(plan.scheduleDays) : Math.max(plan._end.diff(plan._start, 'day') + 1, 1) + if (!startDate || !endDate) return + const planTaskId = `plan-${device.deviceId}-${plan.taskDetailId ?? index}-${index}` + tasks.push({ + id: planTaskId, + text: `${plan.taskCode ?? '-'} / ${plan.productName ?? '-'} / ${plan.planNumber ?? 0}`, + start_date: startDate, + end_date: endDate, + duration, + parent: deviceId, + progress: 0, + _planData: plan, + _deviceData: device + }) + if (previousPlanTaskId) { + links.push({ + id: `link-${linkIndex++}`, + source: previousPlanTaskId, + target: planTaskId, + type: '0' + }) + } + previousPlanTaskId = planTaskId + }) + }) + + return { data: tasks, links } +} + +const destroyGantt = () => { + ganttEventIds.value.forEach((eventId) => gantt.detachEvent(eventId)) + ganttEventIds.value = [] + gantt.clearAll() +} + +const formatGridDateText = (value: unknown) => { + const date = dayjs(value) + if (!date.isValid()) return '-' + return date.format('YYYY-MM-DD HH:mm:ss') +} + +const syncPlanTimeFromTask = (task: any) => { + if (!task?._planData) return + const start = dayjs(task.start_date) + const end = dayjs(task.end_date) + if (!start.isValid() || !end.isValid()) return + task._planData.planStartTimeStr = start.format('YYYY-MM-DD HH:mm:ss') + task._planData.planEndTimeStr = end.format('YYYY-MM-DD HH:mm:ss') + task._planData.scheduleDays = Math.max(end.endOf('day').diff(start.startOf('day'), 'day') + 1, 1) + activePreviewDevice.value = task._deviceData +} + +const refreshTimelineRangeByTasks = () => { + let minStart = Number.POSITIVE_INFINITY + let maxEnd = Number.NEGATIVE_INFINITY + gantt.eachTask((task: any) => { + if (!task?._planData) return + const start = dayjs(task.start_date).valueOf() + const end = dayjs(task.end_date).valueOf() + if (!Number.isFinite(start) || !Number.isFinite(end)) return + minStart = Math.min(minStart, start) + maxEnd = Math.max(maxEnd, end) + }) + if (!Number.isFinite(minStart) || !Number.isFinite(maxEnd)) return + + const nextStartDate = dayjs(minStart).startOf('day').subtract(1, 'day').toDate() + const nextEndDate = dayjs(maxEnd).endOf('day').add(1, 'day').toDate() + const currentStart = dayjs(gantt.config.start_date).valueOf() + const currentEnd = dayjs(gantt.config.end_date).valueOf() + const nextStart = dayjs(nextStartDate).valueOf() + const nextEnd = dayjs(nextEndDate).valueOf() + if (currentStart === nextStart && currentEnd === nextEnd) return + + gantt.config.start_date = nextStartDate + gantt.config.end_date = nextEndDate + gantt.render() +} + +const initGanttPreview = () => { + if (!ganttContainerRef.value || !previewScheduleList.value.length) return + destroyGantt() + + gantt.plugins({ tooltip: true }) // 开启鼠标悬浮提示 + gantt.config.readonly = false // false 表示可编辑,允许拖拽调整任务 + gantt.config.drag_move = true // 允许拖动任务条改变开始时间 + gantt.config.drag_links = false // 禁止通过拖拽创建任务依赖线 + gantt.config.drag_progress = false // 禁止拖拽修改进度 + gantt.config.drag_resize = true // 允许拖拽任务条两端调整时长 + gantt.config.details_on_dblclick = false // 双击不弹出内置详情弹窗 + gantt.config.show_progress = false // 隐藏任务条上的进度显示 + gantt.config.row_height = 40 // 每行高度 + gantt.config.scale_height = 44 // 顶部时间刻度区高度 + gantt.config.xml_date = '%Y-%m-%d %H:%i' // parse 数据里 start_date 的时间格式 + gantt.config.task_height = 24 // 任务条高度 + + gantt.config.columns = [ + { + name: 'text', + label: '任务名称', + tree: true, + width: '*', + min_width: 220 + }, + { + name: 'start_date', + label: '开始时间', + align: 'center', + width: 200, + template: (task: any) => + task?._planData + ? `${formatGridDateText(task.start_date)}` + : formatGridDateText(task.start_date), + editor: { + type: 'date', + map_to: 'start_date' + } + }, + { + name: 'duration', + label: '天数', + align: 'center', + width: 80, + template: (task: any) => + task?._planData + ? `${task.duration ?? 0}` + : String(task.duration ?? 0), + editor: { + type: 'number', + map_to: 'duration', + min: 1 + } + } + ] + gantt.config.scales = [ + { unit: 'month', step: 1, format: (date) => dayjs(date).format('YYYY年M月') }, + { unit: 'day', step: 1, format: (date) => dayjs(date).format('MM-DD') } + ] + gantt.templates.tooltip_text = (start, end, task: any) => { + const plan = task._planData + if (plan) { + return ` +
产品名称:${plan.productName ?? '-'}
+
计划数量:${plan.planNumber ?? '-'}
+
开始:${dayjs(start).format('YYYY-MM-DD HH:mm:ss')}
+
结束:${dayjs(end).format('YYYY-MM-DD HH:mm:ss')}
+ ` + } + const device = task._deviceData + const plans = device?.plans ?? [] + return ` +
设备:${device?.deviceName ?? '-'}
+
计划条数:${plans.length}
+ ` + } + gantt.templates.task_class = (_start, _end, task: any) => (task?._planData ? 'schedule-plan-task' : '') + + const globalRange = getGlobalDateRange(previewScheduleList.value) + gantt.config.start_date = dayjs(globalRange.start).startOf('day').toDate() + gantt.config.end_date = dayjs(globalRange.end).endOf('day').toDate() + gantt.init('gantt_here') + + const ganttData = buildPreviewGanttData(previewScheduleList.value) + gantt.parse(ganttData) + + if (ganttData.data.length) { + activePreviewDevice.value = ganttData.data[0]._deviceData + gantt.showDate(gantt.config.start_date) + } + + const clickEventId = gantt.attachEvent('onTaskClick', (id, event: MouseEvent) => { + const task = gantt.getTask(id) + activePreviewDevice.value = task?._deviceData + const target = event?.target as HTMLElement | null + const editableNode = target?.closest('.gantt-inline-editor-trigger') as HTMLElement | null + const field = editableNode?.dataset?.field + const inlineEditors = (gantt.ext as any)?.inlineEditors + if (field && task?._planData && inlineEditors?.startEdit) { + inlineEditors.startEdit(id, field) + return false + } + return true + }) + + const dragEventId = gantt.attachEvent('onAfterTaskDrag', (id) => { + syncPlanTimeFromTask(gantt.getTask(id)) + refreshTimelineRangeByTasks() + }) + + const updateEventId = gantt.attachEvent('onAfterTaskUpdate', (id) => { + syncPlanTimeFromTask(gantt.getTask(id)) + refreshTimelineRangeByTasks() + gantt.refreshTask(id) + }) + + ganttEventIds.value.push(clickEventId, dragEventId, updateEventId) +} + const handleSubmit = async () => { if (searchForm.sortRule === undefined) { message.warning('请选择排产规则') @@ -463,7 +768,7 @@ const handleSubmit = async () => { reyaNumber: planNumber, // Image fields - orderPriority: row.orderPriority || row._parentTaskOrderPriority || 0, + orderPriority: row.isUrgent || row._parentTaskOrderPriority, workOrderCode: row.taskCode, deliveryDate: row._parentTaskDeliveryDate || new Date().getTime(), // Fallback orderDetailDeliveryDate: row.deliveryDate || row.finishDate || row._parentTaskDeliveryDate || new Date().getTime(), // Fallback @@ -471,13 +776,20 @@ const handleSubmit = async () => { } }) - await TaskApi.oneClickSchedule({ + const scheduleResult = await TaskApi.oneClickSchedule({ createReqVO, sortRule: searchForm.sortRule, capacityType: searchForm.capacityType }) + const scheduleData = Array.isArray(scheduleResult) + ? scheduleResult + : Array.isArray((scheduleResult as any)?.data) + ? (scheduleResult as any).data + : [] + previewScheduleList.value = scheduleData + activePreviewDevice.value = previewScheduleList.value[0] message.success('排产已提交') - dialogVisible.value = false + previewVisible.value = true emit('success') } finally { submitLoading.value = false @@ -489,5 +801,93 @@ const open = async () => { await loadTaskList() } +watch(previewVisible, async (visible) => { + if (visible) { + await nextTick() + initGanttPreview() + return + } + destroyGantt() +}) + +onBeforeUnmount(() => { + destroyGantt() +}) + defineExpose({ open }) + +