mes-vue/src/views/biz/orderProject/components/GanttSchedule.vue

677 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="gantt-schedule-page">
<div class="gantt-header">
<div class="gantt-header-left">
<h1 class="page-title">甘特图排产</h1>
<p class="page-desc">拖拽工序条块调整计划时间完成后请保存排产草稿</p>
</div>
<n-space>
<n-button type="primary" :loading="saving" @click="handleSaveDraft">保存排产草稿</n-button>
<n-button @click="handleBack">返回</n-button>
</n-space>
</div>
<div class="gantt-meta">
<span>生产订单 {{ scheduleStats.moCount }} </span>
<span class="meta-divider">|</span>
<span>工序 {{ scheduleStats.routeCount }} </span>
<span class="meta-divider">|</span>
<span>今日 {{ todayLabel }}</span>
<span class="meta-divider">|</span>
<span class="meta-legend"><i class="legend-today-line" />今日时间轴</span>
</div>
<div class="gantt-panel">
<div class="gantt-panel-head">
<span class="panel-title">排产明细</span>
<span class="panel-tip">主产品行展示物料编码与名称子行展示工序号与工序名称</span>
</div>
<div class="gantt-container" ref="ganttContainer" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, toRaw, computed } from 'vue'
import { useMessage } from 'naive-ui'
import { useRouter } from 'vue-router'
import { gantt } from 'dhtmlx-gantt'
import 'dhtmlx-gantt/codebase/dhtmlxgantt.css'
import { orderProjectApi, normalizeKingdeeProcessRoute, resolveProcessName, type KingdeePrdMo } from '@/api/orderProject'
const router = useRouter()
const ganttContainer = ref<HTMLElement | null>(null)
const message = useMessage()
let ganttEventIds: string[] = []
const tableData = ref<any[]>([])
const projectId = ref<number | null>(null)
const saving = ref(false)
const PROCESS_COLORS = [
'#409eff', '#67c23a', '#e6a23c', '#909399', '#f56c6c',
'#36cfc9', '#597ef7', '#9254de', '#73d13d',
]
const WEEKDAY_LABELS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function getRouteColorIndex(index: number) {
return index % PROCESS_COLORS.length
}
function getRouteColor(index: number) {
return PROCESS_COLORS[getRouteColorIndex(index)]
}
function formatDayScale(date: Date) {
return `${date.getDate()}${WEEKDAY_LABELS[date.getDay()]}`
}
const scheduleStats = computed(() => {
let routeCount = 0
tableData.value.forEach((mo) => {
routeCount += mo.processRoutes?.length ?? 0
})
return {
moCount: tableData.value.length,
routeCount,
}
})
const todayLabel = computed(() => {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
})
function handleBack() {
router.back()
}
async function handleSaveDraft() {
if (!projectId.value) {
message.warning('缺少项目ID无法保存')
return
}
saving.value = true
try {
const draftData = tableData.value.map(mo => {
const { processRoutes, ...rest } = mo
return { ...rest, children: processRoutes } as KingdeePrdMo
})
await orderProjectApi.saveKingdeeOrderDraft(projectId.value, draftData)
message.success('排产草稿保存成功')
sessionStorage.setItem('gantt_schedule_data', JSON.stringify(tableData.value))
} finally {
saving.value = false
}
}
onMounted(async () => {
try {
initGantt()
const stateStr = sessionStorage.getItem('gantt_schedule_data')
const idStr = sessionStorage.getItem('gantt_project_id')
if (idStr) {
projectId.value = Number(idStr)
}
if (stateStr) {
const parsedData = JSON.parse(stateStr)
tableData.value = (Array.isArray(parsedData) ? parsedData : []).map((mo: any) => ({
...mo,
processRoutes: (mo.processRoutes ?? []).map(normalizeKingdeeProcessRoute),
}))
renderGanttData()
} else {
message.warning('未获取到排产数据')
}
} catch {
message.error('加载排产数据失败')
}
})
function formatGanttDate(date: Date) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function formatGridDate(value: Date | string | undefined) {
if (!value) return ''
const date = value instanceof Date ? value : new Date(String(value).replace(' ', 'T'))
if (isNaN(date.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function formatMoMaterialLabel(mo: any) {
const code = mo.materialCode?.trim() || ''
const name = mo.materialName?.trim() || ''
if (code && name) return `${code} ${name}`
return code || name
}
function initGantt() {
if (!ganttContainer.value) return
ganttEventIds.forEach(id => gantt.detachEvent(id))
ganttEventIds = []
gantt.clearAll()
gantt.config.date_format = '%Y-%m-%d %H:%i:%s'
gantt.config.scale_height = 60
gantt.config.row_height = 40
gantt.config.bar_height = 26
gantt.config.scales = [
{ unit: 'month', step: 1, format: '%Y年 %m月' },
{ unit: 'day', step: 1, format: formatDayScale },
]
gantt.config.columns = [
{
name: 'text',
label: '工序号 / 物料编码',
tree: true,
width: 200,
resize: true,
template(task: any) {
if (task._type === 'route') return task.operNumber ?? ''
return task.materialCode ?? ''
},
},
{
name: 'routeProcessName',
label: '工序名称 / 物料名称',
align: 'center',
width: 168,
resize: true,
template(task: any) {
if (task._type === 'route') {
return task.routeProcessName ?? resolveProcessName(task._raw ?? {}) ?? ''
}
return task.materialName ?? ''
},
},
{
name: 'start_date',
label: '计划开始',
align: 'center',
width: 118,
template(task: any) {
return formatGridDate(task.start_date)
},
},
{
name: 'end_date',
label: '计划结束',
align: 'center',
width: 118,
template(task: any) {
return formatGridDate(task.end_date)
},
},
]
gantt.config.layout = {
css: 'gantt_container',
cols: [
{
width: 620,
minWidth: 420,
maxWidth: 680,
rows: [
{ view: 'grid', scrollX: 'gridScroll', scrollable: true, scrollY: 'scrollVer' },
{ view: 'scrollbar', id: 'gridScroll', group: 'horizontal' },
],
},
{ resizer: true, width: 1 },
{
rows: [
{ view: 'timeline', scrollX: 'scrollHor', scrollY: 'scrollVer' },
{ view: 'scrollbar', id: 'scrollHor', group: 'horizontal' },
],
},
{ view: 'scrollbar', id: 'scrollVer' },
],
}
gantt.config.drag_progress = false
gantt.config.drag_links = false
// 使用自然日历(含周六日),不按工作日历自动顺延
gantt.config.work_time = false
gantt.config.correct_work_time = false
gantt.config.skip_off_time = false
gantt.config.duration_unit = 'day'
gantt.config.smart_rendering = true
gantt.i18n.setLocale('cn')
gantt.plugins({ marker: true })
gantt.templates.task_text = function(_start, _end, task) {
if (task._type === 'route') {
return task.routeProcessName || resolveProcessName(task._raw ?? {}) || ''
}
return task.materialName || task.materialLabel || ''
}
gantt.templates.task_class = function(_start, _end, task) {
if (task._type === 'mo') return 'gantt-task-mo'
if (task._type === 'route') {
return `gantt-task-route gantt-route-c-${task.colorIndex ?? 0}`
}
return ''
}
gantt.templates.grid_row_class = function(_start, _end, task) {
if (task._type === 'mo') return 'gantt-row-mo'
if (task._type === 'route') {
return `gantt-row-route gantt-route-c-${task.colorIndex ?? 0}`
}
return ''
}
gantt.templates.timeline_cell_class = function(_task, date) {
const day = date.getDay()
if (day === 0 || day === 6) return 'gantt-weekend'
return ''
}
gantt.init(ganttContainer.value)
}
/** 今日时间轴标记(数据刷新后需重新添加) */
function addTodayMarker() {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0)
gantt.addMarker({
start_date: today,
css: 'today-marker',
text: '今日',
title: `今日: ${formatGanttDate(now)}`,
})
gantt.showDate(today)
}
function getRouteProcessName(route: any) {
return resolveProcessName(route)
}
function renderGanttData() {
gantt.clearAll()
const tasks: any[] = []
const rawData = toRaw(tableData.value) || []
rawData.forEach((mo: any, moIndex: number) => {
const moId = `mo_${moIndex}_${mo.productionOrderNo}_${mo.billNo}`
let minDate: Date | null = mo.planStartTime ? new Date(mo.planStartTime.replace(' ', 'T')) : null
let maxDate: Date | null = mo.planFinishTime ? new Date(mo.planFinishTime.replace(' ', 'T')) : null
if (!minDate || isNaN(minDate.getTime())) minDate = new Date()
if (!maxDate || isNaN(maxDate.getTime())) maxDate = new Date(minDate.getTime() + 86400000)
if (maxDate <= minDate) maxDate = new Date(minDate.getTime() + 86400000)
const materialLabel = formatMoMaterialLabel(mo)
tasks.push({
id: moId,
text: materialLabel,
materialLabel,
materialCode: mo.materialCode || '',
materialName: mo.materialName || '',
operNumber: '',
processName: '',
start_date: formatGanttDate(minDate),
end_date: formatGanttDate(maxDate),
open: true,
type: gantt.config.types.project,
_type: 'mo',
progress: 0,
_raw: mo,
})
if (mo.processRoutes?.length > 0) {
mo.processRoutes.forEach((route: any, index: number) => {
const routeId = `route_${moId}_${index}`
let rStart = route.planStartTime ? new Date(route.planStartTime.replace(' ', 'T')) : null
let rEnd = route.planFinishTime ? new Date(route.planFinishTime.replace(' ', 'T')) : null
if (!rStart || isNaN(rStart.getTime())) rStart = minDate
if (!rEnd || isNaN(rEnd.getTime())) rEnd = maxDate
if (rEnd <= rStart) rEnd = new Date(rStart!.getTime() + 3600000)
const operNumber = route.operNumber != null ? String(route.operNumber) : String(index + 1)
const routeProcessName = getRouteProcessName(route)
const colorIndex = getRouteColorIndex(index)
const routeColor = getRouteColor(index)
tasks.push({
id: routeId,
text: operNumber,
operNumber,
routeProcessName,
colorIndex,
routeColor,
color: routeColor,
start_date: formatGanttDate(rStart!),
end_date: formatGanttDate(rEnd),
parent: moId,
_type: 'route',
progress: 0,
_raw: route,
})
})
}
})
gantt.parse({ data: tasks, links: [] })
addTodayMarker()
const evDragId = gantt.attachEvent('onBeforeTaskDrag', (id) => {
const task = gantt.getTask(id)
return task.type !== gantt.config.types.project
})
ganttEventIds.push(evDragId)
const evId = gantt.attachEvent('onAfterTaskDrag', (id, mode) => {
const task = gantt.getTask(id)
if (mode === 'move' || mode === 'resize') {
const start = gantt.date.date_to_str(gantt.config.date_format)(task.start_date)
const end = gantt.date.date_to_str(gantt.config.date_format)(task.end_date)
if (task._type === 'mo') {
task._raw.planStartTime = start
task._raw.planFinishTime = end
} else if (task._type === 'route') {
task._raw.planStartTime = start
task._raw.planFinishTime = end
const parentId = task.parent
if (parentId) {
const parentTask = gantt.getTask(parentId)
const children = gantt.getChildren(parentId)
let minD: Date | null = null
let maxD: Date | null = null
children.forEach((childId: string | number) => {
const childTask = gantt.getTask(childId)
const cs = childTask.start_date
const ce = childTask.end_date
if (cs && (!minD || cs < minD)) minD = cs
if (ce && (!maxD || ce > maxD)) maxD = ce
})
if (minD && maxD) {
parentTask.start_date = new Date(minD)
parentTask.end_date = new Date(maxD)
parentTask._raw.planStartTime = gantt.date.date_to_str(gantt.config.date_format)(minD)
parentTask._raw.planFinishTime = gantt.date.date_to_str(gantt.config.date_format)(maxD)
gantt.updateTask(parentId)
}
}
}
const label = task._type === 'route'
? (task.routeProcessName || task.operNumber)
: (task.materialLabel || task.materialName)
message.success(`任务 [${label}] 计划时间已更新`)
}
})
ganttEventIds.push(evId)
}
onUnmounted(() => {
ganttEventIds.forEach(id => gantt.detachEvent(id))
gantt.clearAll()
})
</script>
<style scoped>
.gantt-schedule-page {
--gs-font: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
--gs-title: #333333;
--gs-text: #606266;
--gs-border: #e4e7ed;
--gs-bg: #f5f7fa;
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
padding: 16px;
box-sizing: border-box;
font-family: var(--gs-font);
font-weight: 400;
color: var(--gs-text);
background: var(--gs-bg);
}
.gantt-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
margin-bottom: 12px;
padding: 14px 16px;
background: #fff;
border: 1px solid var(--gs-border);
border-radius: 4px;
}
.gantt-header-left {
min-width: 0;
}
.page-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--gs-title);
line-height: 1.4;
}
.page-desc {
margin: 4px 0 0;
font-size: 13px;
font-weight: 400;
color: var(--gs-text);
line-height: 1.5;
}
.gantt-meta {
flex-shrink: 0;
margin-bottom: 12px;
padding: 0 4px;
font-size: 13px;
font-weight: 400;
color: var(--gs-text);
}
.meta-divider {
margin: 0 10px;
color: #dcdfe6;
}
.meta-legend {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-today-line {
display: inline-block;
width: 2px;
height: 14px;
background: #f56c6c;
border-radius: 1px;
}
.gantt-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid var(--gs-border);
border-radius: 4px;
overflow: hidden;
}
.gantt-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
background: #fafafa;
border-bottom: 1px solid var(--gs-border);
flex-shrink: 0;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: var(--gs-title);
}
.panel-tip {
font-size: 12px;
font-weight: 400;
color: #909399;
}
.gantt-container {
flex: 1;
width: 100%;
min-height: 0;
overflow: hidden;
}
/* dhtmlx-gantt仅表头标题加粗其余正常字重 */
.gantt-container :deep(.gantt_container) {
font-family: var(--gs-font);
font-weight: 400;
border: none !important;
background: #fff;
}
.gantt-container :deep(.gantt_grid_scale),
.gantt-container :deep(.gantt_task_scale) {
background: #fafafa;
border-bottom: 1px solid var(--gs-border) !important;
}
.gantt-container :deep(.gantt_task_scale .gantt_scale_cell) {
font-size: 12px;
font-weight: 400;
color: var(--gs-text);
border-color: var(--gs-border) !important;
line-height: 1.3;
white-space: normal;
}
.gantt-container :deep(.gantt_grid_scale .gantt_grid_head_cell) {
font-size: 13px;
font-weight: 600;
color: var(--gs-title);
border-color: var(--gs-border) !important;
}
/* 工序号文字与进度条同色(无圆点) */
.gantt-container :deep(.gantt_row.gantt-row-route.gantt-route-c-0 .gantt_cell:first-child) { color: #409eff; }
.gantt-container :deep(.gantt_row.gantt-row-route.gantt-route-c-1 .gantt_cell:first-child) { color: #67c23a; }
.gantt-container :deep(.gantt_row.gantt-row-route.gantt-route-c-2 .gantt_cell:first-child) { color: #e6a23c; }
.gantt-container :deep(.gantt_row.gantt-row-route.gantt-route-c-3 .gantt_cell:first-child) { color: #909399; }
.gantt-container :deep(.gantt_row.gantt-row-route.gantt-route-c-4 .gantt_cell:first-child) { color: #f56c6c; }
.gantt-container :deep(.gantt_row.gantt-row-route.gantt-route-c-5 .gantt_cell:first-child) { color: #36cfc9; }
.gantt-container :deep(.gantt_row.gantt-row-route.gantt-route-c-6 .gantt_cell:first-child) { color: #597ef7; }
.gantt-container :deep(.gantt_row.gantt-row-route.gantt-route-c-7 .gantt_cell:first-child) { color: #9254de; }
.gantt-container :deep(.gantt_row.gantt-row-route.gantt-route-c-8 .gantt_cell:first-child) { color: #73d13d; }
.gantt-container :deep(.gantt_task_line.gantt-route-c-0) { background-color: #409eff !important; border-color: #409eff !important; }
.gantt-container :deep(.gantt_task_line.gantt-route-c-1) { background-color: #67c23a !important; border-color: #67c23a !important; }
.gantt-container :deep(.gantt_task_line.gantt-route-c-2) { background-color: #e6a23c !important; border-color: #e6a23c !important; }
.gantt-container :deep(.gantt_task_line.gantt-route-c-3) { background-color: #909399 !important; border-color: #909399 !important; }
.gantt-container :deep(.gantt_task_line.gantt-route-c-4) { background-color: #f56c6c !important; border-color: #f56c6c !important; }
.gantt-container :deep(.gantt_task_line.gantt-route-c-5) { background-color: #36cfc9 !important; border-color: #36cfc9 !important; }
.gantt-container :deep(.gantt_task_line.gantt-route-c-6) { background-color: #597ef7 !important; border-color: #597ef7 !important; }
.gantt-container :deep(.gantt_task_line.gantt-route-c-7) { background-color: #9254de !important; border-color: #9254de !important; }
.gantt-container :deep(.gantt_task_line.gantt-route-c-8) { background-color: #73d13d !important; border-color: #73d13d !important; }
.gantt-container :deep(.gantt_task_line.gantt-task-route .gantt_task_progress) {
background-color: rgba(0, 0, 0, 0.18) !important;
opacity: 1;
}
.gantt-container :deep(.gantt_task_line.gantt-task-route) {
border: none !important;
border-radius: 3px;
}
.gantt-container :deep(.gantt_grid_data .gantt_cell) {
font-size: 13px;
font-weight: 400;
color: var(--gs-text);
border-color: #f0f2f5 !important;
}
.gantt-container :deep(.gantt_row.gantt-row-mo) {
background: #fafafa;
}
.gantt-container :deep(.gantt_row.gantt-row-mo .gantt_cell) {
font-weight: 400;
color: var(--gs-title);
}
.gantt-container :deep(.gantt_row:hover) {
background: #f5f7fa !important;
}
.gantt-container :deep(.gantt_task_row.gantt_selected),
.gantt-container :deep(.gantt_row.gantt_selected) {
background: #ecf5ff !important;
}
.gantt-container :deep(.gantt_task_line.gantt-task-route .gantt_task_content) {
font-size: 12px;
font-weight: 400;
color: #fff;
padding: 0 6px;
}
.gantt-container :deep(.gantt_task_line.gantt_project) {
display: none;
}
.gantt-container :deep(.gantt_weekend) {
background: #fafafa;
}
.gantt-container :deep(.gantt_marker.today-marker) {
background: #f56c6c;
width: 2px;
z-index: 4;
opacity: 1;
}
.gantt-container :deep(.today-marker .gantt_marker_content) {
background: #f56c6c;
color: #fff;
font-size: 12px;
font-weight: 600;
border-radius: 2px;
padding: 2px 8px;
box-shadow: 0 1px 4px rgba(245, 108, 108, 0.35);
}
.gantt-container :deep(.gantt_layout_content) {
border-color: var(--gs-border) !important;
}
.gantt-container :deep(.gantt_hor_scroll),
.gantt-container :deep(.gantt_ver_scroll) {
background: #fafafa;
}
</style>