gaoluyang
2026-06-24 712aa51536236d43e87273e4ce45ac5691dffad8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
 
import { BarcodeBizTypeEnum } from '..\..\..\..\..\packages\constants\src';
 
import { gantt } from 'dhtmlx-gantt';
 
import 'dhtmlx-gantt/codebase/dhtmlxgantt.css';
 
/**
 * 甘特图组件(基于 dhtmlx-gantt)
 *
 * 1. 按工单分组展示生产任务,工单为 project 行,任务为子行
 * 2. 支持只读预览和拖拽编辑两种模式
 * 3. 拖拽后触发 taskUpdate 事件,通知父组件批量保存
 * 4. 时间刻度:周 → 日 → 8 小时(1 工作日 = 8 小时)
 */
defineOptions({ name: 'GanttChart', inheritAttrs: false });
 
const props = withDefaults(
  defineProps<{
    height?: number; // 甘特图高度
    readonly?: boolean; // 是否只读
    tasks?: any[]; // 甘特图任务数据
  }>(),
  {
    height: 350,
    readonly: false,
    tasks: () => [],
  },
);
 
const emit = defineEmits<{
  taskClick: [id: number | string];
  taskUpdate: [task: any];
}>();
 
const ganttContainer = ref<HTMLElement>(); // 甘特图挂载容器
const ganttInited = ref(false); // 甘特图是否已初始化
 
/** 初始化甘特图配置 */
function initGantt() {
  if (!ganttContainer.value) {
    return;
  }
  gantt.i18n.setLocale('cn');
 
  gantt.config.readonly = props.readonly;
  gantt.config.date_format = '%Y-%m-%d %H:%i:%s';
  gantt.config.duration_unit = 'hour'; // 持续时间单位为小时,配合 duration_step 实现工作日单位
  gantt.config.duration_step = 8; // 1 工作日 = 8 小时
  gantt.config.row_height = 36;
  gantt.config.bar_height = 24;
  gantt.config.fit_tasks = true;
  gantt.config.auto_scheduling = false;
  gantt.config.drag_links = false;
  gantt.config.details_on_create = true;
  gantt.config.details_on_dblclick = true;
  gantt.config.show_progress = true;
  gantt.config.open_tree_initially = true;
  gantt.config.auto_types = false;
  gantt.config.drag_move = !props.readonly;
  gantt.config.drag_resize = !props.readonly;
  gantt.config.drag_progress = false;
 
  // lightbox 弹窗只保留时间编辑,去掉描述编辑和删除按钮
  gantt.config.lightbox.sections = [
    { name: 'time', type: 'duration', map_to: 'auto' },
  ];
  gantt.config.buttons_left = ['gantt_save_btn'];
  gantt.config.buttons_right = ['gantt_cancel_btn'];
 
  // 时间刻度:周 > 日 > 8 小时
  const weekScaleTemplate = (date: Date) => {
    const dateToStr = gantt.date.date_to_str('%M %d');
    const endDate = gantt.date.add(gantt.date.add(date, 1, 'week'), -1, 'day');
    return `${dateToStr(date)} - ${dateToStr(endDate)}`;
  };
  const dayTemplate = (date: Date) => gantt.date.date_to_str('%M %d')(date);
  const daysStyle = (date: Date) =>
    date.getDay() === 0 || date.getDay() === 6 ? 'weekend' : '';
  gantt.config.scales = [
    { unit: 'week', step: 1, format: weekScaleTemplate },
    { unit: 'day', step: 1, format: dayTemplate, css: daysStyle },
    { unit: 'hour', step: 8, format: '%H:%i' },
  ];
  gantt.config.scale_height = 50;
  gantt.config.show_task_cells = true;
 
  gantt.config.columns = [
    { name: 'text', label: '任务名称', tree: true, width: 180, resize: true },
    {
      name: 'workstation',
      label: '工作站',
      align: 'center',
      width: 100,
      resize: true,
    },
    {
      name: 'process',
      label: '工序',
      align: 'center',
      width: 100,
      resize: true,
    },
    { name: 'start_date', label: '开始时间', align: 'center', width: 130 },
    { name: 'end_date', label: '结束时间', align: 'center', width: 130 },
  ];
 
  gantt.plugins({ marker: true, tooltip: true });
  gantt.addMarker({ start_date: new Date(), css: 'today', text: '今天' });
 
  gantt.templates.task_text = (_start: any, _end: any, task: any) => {
    const percent = Math.round((task.progress || 0) * 100);
    if (task.type === 'project') {
      return `<b>生产工单:</b> ${task.text} <span>完成比例:${percent}%</span>`;
    }
    return `<b>生产任务:</b> ${task.process || ''} ${task.text} <span>完成比例:${percent}%</span>`;
  };
 
  gantt.templates.tooltip_text = (_start: any, _end: any, task: any) => {
    const percent = Math.round((task.progress || 0) * 100);
    if (task.type === 'project') {
      return `<b>生产工单:</b> ${task.text} <span>完成比例:${percent}%</span>`;
    }
    return `<b>生产任务:</b> ${task.process || ''} ${task.text} <span>完成比例:${percent}%</span>`;
  };
 
  gantt.templates.task_class = (_start: any, _end: any, task: any) =>
    task.type === gantt.config.types.project ? 'gantt-project-bar' : '';
  gantt.templates.timeline_cell_class = () => '';
  gantt.templates.task_row_class = () => '';
 
  // 编辑事件监听(通过 lightbox 弹窗编辑后触发)
  if (!props.readonly) {
    gantt.attachEvent('onAfterTaskUpdate', (id: number | string) => {
      const task = gantt.getTask(id);
      // 只回写 task 节点,避免把工单(project)节点当成任务保存
      if (task.type !== gantt.config.types.task || !task.originalId) {
        return;
      }
      emit('taskUpdate', {
        duration: task.duration,
        endTime: task.end_date,
        id: task.originalId,
        startTime: task.start_date,
      });
    });
  }
 
  gantt.attachEvent('onTaskClick', (id: number | string) => {
    emit('taskClick', id);
    return true;
  });
 
  gantt.init(ganttContainer.value);
  ganttInited.value = true;
}
 
/** 加载数据到甘特图 */
function loadData(tasks: any[]) {
  if (!ganttInited.value) {
    return;
  }
  gantt.clearAll();
  // 后端 type 使用整数业务类型,需映射为 gantt 类型字符串
  const typeMap: Record<number, string> = {
    [BarcodeBizTypeEnum.WORKORDER]: 'project',
    [BarcodeBizTypeEnum.TASK]: 'task',
  };
  gantt.parse({
    data: tasks.map((item: any) => ({
      ...item,
      type: typeMap[item.type] || item.type,
      start_date: item.startDate ? new Date(item.startDate) : undefined,
      end_date: item.endDate ? new Date(item.endDate) : undefined,
    })),
    links: [],
  });
}
 
watch(
  () => props.tasks,
  (val) => {
    if (val?.length && ganttInited.value) {
      loadData(val);
    }
  },
  { deep: true },
);
 
onMounted(() => {
  initGantt();
  if (props.tasks?.length) {
    loadData(props.tasks);
  }
});
 
onBeforeUnmount(() => {
  if (ganttInited.value) {
    gantt.clearAll();
  }
});
 
defineExpose({ loadData });
</script>
 
<template>
  <div
    ref="ganttContainer"
    :style="{ width: '100%', height: `${height}px` }"
  ></div>
</template>
 
<style>
/* dhtmlx-gantt 第三方类名使用下划线命名,无法重命名,故禁用 BEM 类名规则 */
/* stylelint-disable selector-class-pattern */
 
/* 今天标记线 */
.gantt_marker.today {
  background-color: #f44;
  opacity: 0.4;
}
 
.gantt_marker.today .gantt_marker_content {
  font-size: 12px;
  color: #f44;
}
 
/* 工单(project)行样式 */
.gantt-project-bar .gantt_task_progress {
  background: #7b68ee;
}
 
/* 甘特条圆角 */
.gantt_task_line {
  border-radius: 8px;
}
 
/* 周末背景色 */
.weekend {
  background: #f0f0f0 !important;
}
 
/* 行悬浮高亮 */
.gantt_grid_data .gantt_row:hover,
.gantt_grid_data .gantt_row.odd:hover {
  background-color: #f3f1fe !important;
}
 
/* 选中行高亮 */
.gantt_grid_data .gantt_row.gantt_selected,
.gantt_grid_data .gantt_row.odd.gantt_selected,
.gantt_task_row.gantt_selected {
  background-color: #f3f1fe !important;
}
</style>