feat(mes): 迁移 work task 功能
parent
7bf65041f9
commit
be213b6b31
|
|
@ -40,7 +40,7 @@ const routes: RouteRecordRaw[] = [
|
||||||
component: () => import('#/views/mes/wm/barcode/config/index.vue'),
|
component: () => import('#/views/mes/wm/barcode/config/index.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'pro/task/edit',
|
path: 'pro/task/gantt-edit',
|
||||||
name: 'MesProTaskGanttEdit',
|
name: 'MesProTaskGanttEdit',
|
||||||
meta: {
|
meta: {
|
||||||
title: '甘特图编辑',
|
title: '甘特图编辑',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { gantt } from 'dhtmlx-gantt';
|
||||||
|
|
||||||
|
import { BarcodeBizTypeEnum } from '#/views/mes/utils/constants';
|
||||||
|
|
||||||
|
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>
|
||||||
|
/* 今天标记线 */
|
||||||
|
.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>
|
||||||
|
|
@ -47,23 +47,19 @@ const showClear = computed(() => // 是否显示清空图标
|
||||||
props.allowClear &&
|
props.allowClear &&
|
||||||
!props.disabled &&
|
!props.disabled &&
|
||||||
hovering.value &&
|
hovering.value &&
|
||||||
props.modelValue !== undefined,
|
props.modelValue != null,
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 根据任务编号回显选择器 */
|
/** 根据任务编号回显选择器 */
|
||||||
async function resolveItemById(id: number | undefined) {
|
async function resolveItemById(id: number | undefined) {
|
||||||
if (id === undefined) {
|
if (id == null) {
|
||||||
selectedItem.value = undefined;
|
selectedItem.value = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedItem.value?.id === id) {
|
if (selectedItem.value?.id === id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
selectedItem.value = await getTask(id);
|
||||||
selectedItem.value = await getTask(id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ProTaskSelect] resolveItemById failed:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -92,8 +88,7 @@ function handleClick(event: MouseEvent) {
|
||||||
clearSelected();
|
clearSelected();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectedIds =
|
const selectedIds = props.modelValue == null ? [] : [props.modelValue];
|
||||||
props.modelValue === undefined ? [] : [props.modelValue];
|
|
||||||
dialogRef.value?.open(selectedIds, {
|
dialogRef.value?.open(selectedIds, {
|
||||||
multiple: false,
|
multiple: false,
|
||||||
workOrderId: props.workOrderId,
|
workOrderId: props.workOrderId,
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,15 @@ export function useScheduleFormSchema(): VbenFormSchema[] {
|
||||||
valueFormat: 'x',
|
valueFormat: 'x',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '工单状态',
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
disabled: true,
|
||||||
|
options: getDictOptions(DICT_TYPE.MES_PRO_WORK_ORDER_STATUS, 'number'),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'remark',
|
fieldName: 'remark',
|
||||||
label: '备注',
|
label: '备注',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { DocAlert, Page } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Badge, Button, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getGanttTaskList, updateTask } from '#/api/mes/pro/task';
|
||||||
|
|
||||||
|
import GanttChart from '../components/gantt-chart.vue';
|
||||||
|
|
||||||
|
const submitting = ref(false); // 批量保存按钮加载态
|
||||||
|
const taskList = ref<any[]>([]); // 甘特图任务数据
|
||||||
|
const pendingChanges = ref(new Map<number, any>()); // 待保存的修改 Map<taskId, changeData>
|
||||||
|
|
||||||
|
const pendingCount = computed(() => pendingChanges.value.size); // 待保存数量
|
||||||
|
const ganttHeight = computed(() => window.innerHeight - 220); // 甘特图高度
|
||||||
|
|
||||||
|
/** 加载甘特图数据 */
|
||||||
|
async function loadGanttData() {
|
||||||
|
taskList.value = await getGanttTaskList({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务编辑回调:缓存待保存的修改 */
|
||||||
|
function handleTaskUpdate(change: any) {
|
||||||
|
pendingChanges.value.set(change.id, change);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量保存 */
|
||||||
|
async function handleSave() {
|
||||||
|
if (pendingChanges.value.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const changes = [...pendingChanges.value.values()];
|
||||||
|
await Promise.all(
|
||||||
|
changes.map((change) =>
|
||||||
|
updateTask({
|
||||||
|
duration: change.duration,
|
||||||
|
endTime: change.endTime,
|
||||||
|
id: change.id,
|
||||||
|
startTime: change.startTime,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
message.success(`已保存 ${changes.length} 条修改`);
|
||||||
|
pendingChanges.value = new Map();
|
||||||
|
await loadGanttData();
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新 */
|
||||||
|
async function handleRefresh() {
|
||||||
|
pendingChanges.value = new Map();
|
||||||
|
await loadGanttData();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadGanttData);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<template #doc>
|
||||||
|
<DocAlert
|
||||||
|
title="【生产】生产排产、工序流转卡"
|
||||||
|
url="https://doc.iocoder.cn/mes/pro/schedule-card/"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
可直接拖拽/拉伸任务条,或双击编辑开始时间和时长,修改后点击「批量保存」
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Badge :count="pendingCount">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
:disabled="pendingCount === 0"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
批量保存
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
<Button @click="handleRefresh">刷新</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GanttChart
|
||||||
|
:height="ganttHeight"
|
||||||
|
:readonly="false"
|
||||||
|
:tasks="taskList"
|
||||||
|
@task-update="handleTaskUpdate"
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
|
||||||
|
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Card } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { getGanttTaskList } from '#/api/mes/pro/task';
|
||||||
|
import { getWorkOrderPage } from '#/api/mes/pro/workorder';
|
||||||
|
import {
|
||||||
|
MesProWorkOrderStatusEnum,
|
||||||
|
MesProWorkOrderTypeEnum,
|
||||||
|
} from '#/views/mes/utils/constants';
|
||||||
|
|
||||||
|
import GanttChart from './components/gantt-chart.vue';
|
||||||
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
import ScheduleForm from './modules/schedule-form.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const ganttTasks = ref<any[]>([]); // 甘特图预览数据
|
||||||
|
|
||||||
|
const [ScheduleModal, scheduleModalApi] = useVbenModal({
|
||||||
|
connectedComponent: ScheduleForm,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function handleRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
loadGanttPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载甘特图预览数据 */
|
||||||
|
async function loadGanttPreview() {
|
||||||
|
ganttTasks.value = await getGanttTaskList({
|
||||||
|
status: MesProWorkOrderStatusEnum.CONFIRMED,
|
||||||
|
type: MesProWorkOrderTypeEnum.SELF,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看工单详情 */
|
||||||
|
function handleDetail(row: MesProWorkOrderApi.WorkOrder) {
|
||||||
|
scheduleModalApi.setData({ formType: 'detail', id: row.id }).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排产 */
|
||||||
|
function handleSchedule(row: MesProWorkOrderApi.WorkOrder) {
|
||||||
|
scheduleModalApi.setData({ formType: 'schedule', id: row.id }).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开甘特图编辑页面 */
|
||||||
|
function handleGanttEdit() {
|
||||||
|
router.push({ name: 'MesProTaskGanttEdit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: useGridFormSchema(),
|
||||||
|
},
|
||||||
|
gridOptions: {
|
||||||
|
columns: useGridColumns(),
|
||||||
|
keepSource: true,
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getWorkOrderPage({
|
||||||
|
pageNo: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
status: MesProWorkOrderStatusEnum.CONFIRMED,
|
||||||
|
type: MesProWorkOrderTypeEnum.SELF,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
search: true,
|
||||||
|
},
|
||||||
|
treeConfig: {
|
||||||
|
parentField: 'parentId',
|
||||||
|
rowField: 'id',
|
||||||
|
transform: true,
|
||||||
|
expandAll: true,
|
||||||
|
reserve: true,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<MesProWorkOrderApi.WorkOrder>,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(loadGanttPreview);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<template #doc>
|
||||||
|
<DocAlert
|
||||||
|
title="【生产】生产排产、工序流转卡"
|
||||||
|
url="https://doc.iocoder.cn/mes/pro/schedule-card/"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ScheduleModal @success="handleRefresh" />
|
||||||
|
|
||||||
|
<!-- 排产甘特图预览 -->
|
||||||
|
<Card title="排产甘特图" class="mb-4">
|
||||||
|
<GanttChart :height="350" :readonly="true" :tasks="ganttTasks" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Grid table-title="待排产工单">
|
||||||
|
<template #toolbar-tools>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '甘特图编辑',
|
||||||
|
type: 'primary',
|
||||||
|
icon: ACTION_ICON.EDIT,
|
||||||
|
onClick: handleGanttEdit,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #code="{ row }">
|
||||||
|
<Button type="link" @click="handleDetail(row)">
|
||||||
|
{{ row.code }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '排产',
|
||||||
|
type: 'link',
|
||||||
|
auth: ['mes:pro-task:create'],
|
||||||
|
ifShow: row.status === MesProWorkOrderStatusEnum.CONFIRMED,
|
||||||
|
onClick: handleSchedule.bind(null, row),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
|
||||||
|
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Card, message, Popconfirm, Steps } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import { getRouteProcessListByProduct } from '#/api/mes/pro/route/process';
|
||||||
|
import { finishWorkOrder, getWorkOrder } from '#/api/mes/pro/workorder';
|
||||||
|
|
||||||
|
import { useScheduleFormSchema } from '../data';
|
||||||
|
import TaskList from './task-list.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
const formType = ref<'detail' | 'schedule'>('schedule');
|
||||||
|
const workOrder = ref<MesProWorkOrderApi.WorkOrder>(); // 当前工单详情
|
||||||
|
const routeProcessList = ref<MesProRouteProcessApi.RouteProcess[]>([]); // 工艺路线工序列表
|
||||||
|
const activeProcessStep = ref(0); // 当前工序步骤索引
|
||||||
|
const currentRouteId = ref(0); // 当前工艺路线编号
|
||||||
|
|
||||||
|
const isReadonly = computed(() => formType.value === 'detail'); // 详情态只读
|
||||||
|
const getTitle = computed(() =>
|
||||||
|
formType.value === 'detail' ? '工单详情' : '生产排产',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-1',
|
||||||
|
labelWidth: 100,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useScheduleFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
wrapperClass: 'grid-cols-3',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 加载工艺路线工序列表 */
|
||||||
|
async function loadRouteProcesses(productId: number) {
|
||||||
|
const processes = await getRouteProcessListByProduct(productId);
|
||||||
|
if (!processes || processes.length === 0) {
|
||||||
|
message.warning('当前产品未配置工艺路线,请先在工艺路线中维护');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentRouteId.value = processes[0]!.routeId ?? 0;
|
||||||
|
routeProcessList.value = [...processes].toSorted(
|
||||||
|
(a, b) => (a.sort ?? 0) - (b.sort ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完成工单 */
|
||||||
|
async function handleFinish() {
|
||||||
|
if (!workOrder.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
await finishWorkOrder(workOrder.value.id);
|
||||||
|
message.success('工单已完成');
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
showConfirmButton: false,
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
workOrder.value = undefined;
|
||||||
|
routeProcessList.value = [];
|
||||||
|
activeProcessStep.value = 0;
|
||||||
|
currentRouteId.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 加载数据
|
||||||
|
const data = modalApi.getData<{ formType: 'detail' | 'schedule'; id: number }>();
|
||||||
|
formType.value = data.formType;
|
||||||
|
activeProcessStep.value = 0;
|
||||||
|
routeProcessList.value = [];
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
workOrder.value = await getWorkOrder(data.id);
|
||||||
|
// 设置到 values
|
||||||
|
await formApi.setValues(workOrder.value);
|
||||||
|
if (workOrder.value.productId) {
|
||||||
|
await loadRouteProcesses(workOrder.value.productId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :title="getTitle" class="w-4/5">
|
||||||
|
<Form class="mx-4" />
|
||||||
|
<!-- 工序步骤导航 + 当前工序任务列表 -->
|
||||||
|
<template v-if="routeProcessList.length > 0 && workOrder?.id">
|
||||||
|
<Steps
|
||||||
|
v-model:current="activeProcessStep"
|
||||||
|
class="my-4 px-4"
|
||||||
|
size="small"
|
||||||
|
type="navigation"
|
||||||
|
>
|
||||||
|
<Steps.Step
|
||||||
|
v-for="rp in routeProcessList"
|
||||||
|
:key="rp.processId"
|
||||||
|
:title="rp.processName"
|
||||||
|
/>
|
||||||
|
</Steps>
|
||||||
|
<Card
|
||||||
|
v-for="(rp, index) in routeProcessList"
|
||||||
|
v-show="activeProcessStep === index"
|
||||||
|
:key="rp.processId"
|
||||||
|
class="mx-4"
|
||||||
|
>
|
||||||
|
<TaskList
|
||||||
|
:color-code="rp.colorCode"
|
||||||
|
:disabled="isReadonly"
|
||||||
|
:item-id="workOrder.productId"
|
||||||
|
:process-id="rp.processId!"
|
||||||
|
:route-id="currentRouteId"
|
||||||
|
:work-order-id="workOrder.id!"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
<template #prepend-footer>
|
||||||
|
<div class="flex flex-auto items-center gap-2">
|
||||||
|
<Popconfirm
|
||||||
|
v-if="formType === 'schedule'"
|
||||||
|
title="确认要完成该工单吗?完成后工单下所有任务将标记为已完成。"
|
||||||
|
@confirm="handleFinish"
|
||||||
|
>
|
||||||
|
<Button type="primary">完成</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MesProTaskApi } from '#/api/mes/pro/task';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import { createTask, getTask, updateTask } from '#/api/mes/pro/task';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useTaskFormSchema } from '../data';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
const formData = ref<MesProTaskApi.Task>();
|
||||||
|
const context = ref<{
|
||||||
|
colorCode?: string;
|
||||||
|
itemId?: number;
|
||||||
|
processId?: number;
|
||||||
|
routeId?: number;
|
||||||
|
workOrderId?: number;
|
||||||
|
}>({}); // 新增任务时的工单 / 工序上下文
|
||||||
|
|
||||||
|
const getTitle = computed(() => {
|
||||||
|
return formData.value?.id
|
||||||
|
? $t('ui.actionTitle.edit', ['生产任务'])
|
||||||
|
: $t('ui.actionTitle.create', ['生产任务']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-1',
|
||||||
|
labelWidth: 100,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: [],
|
||||||
|
showDefaultActions: false,
|
||||||
|
wrapperClass: 'grid-cols-3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
async onConfirm() {
|
||||||
|
const { valid } = await formApi.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
// 提交表单
|
||||||
|
const data = (await formApi.getValues()) as MesProTaskApi.Task;
|
||||||
|
try {
|
||||||
|
await (formData.value?.id
|
||||||
|
? updateTask({ ...formData.value, ...data })
|
||||||
|
: createTask({ ...context.value, ...data }));
|
||||||
|
// 关闭并提示
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
message.success($t('ui.actionMessage.operationSuccess'));
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
formData.value = undefined;
|
||||||
|
context.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formApi.setState({ schema: useTaskFormSchema(formApi) });
|
||||||
|
// 加载数据
|
||||||
|
const data = modalApi.getData<{
|
||||||
|
colorCode?: string;
|
||||||
|
id?: number;
|
||||||
|
itemId?: number;
|
||||||
|
processId?: number;
|
||||||
|
routeId?: number;
|
||||||
|
workOrderId?: number;
|
||||||
|
}>();
|
||||||
|
if (data?.id) {
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
formData.value = await getTask(data.id);
|
||||||
|
// 设置到 values
|
||||||
|
await formApi.setValues(formData.value);
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.value = {
|
||||||
|
colorCode: data.colorCode,
|
||||||
|
itemId: data.itemId,
|
||||||
|
processId: data.processId,
|
||||||
|
routeId: data.routeId,
|
||||||
|
workOrderId: data.workOrderId,
|
||||||
|
};
|
||||||
|
await formApi.setValues({
|
||||||
|
colorCode: data.colorCode || '#00AEF3',
|
||||||
|
duration: 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :title="getTitle" class="w-3/5">
|
||||||
|
<Form class="mx-4" />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { MesProTaskApi } from '#/api/mes/pro/task';
|
||||||
|
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { deleteTask, getTaskPage } from '#/api/mes/pro/task';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useTaskGridColumns } from '../data';
|
||||||
|
import TaskForm from './task-form.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
colorCode?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
itemId?: number;
|
||||||
|
processId: number;
|
||||||
|
routeId: number;
|
||||||
|
workOrderId: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const editable = computed(() => !props.disabled); // 是否可维护任务
|
||||||
|
|
||||||
|
const [TaskFormModal, taskFormModalApi] = useVbenModal({
|
||||||
|
connectedComponent: TaskForm,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function handleRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增任务 */
|
||||||
|
function handleCreate() {
|
||||||
|
taskFormModalApi
|
||||||
|
.setData({
|
||||||
|
colorCode: props.colorCode,
|
||||||
|
itemId: props.itemId,
|
||||||
|
processId: props.processId,
|
||||||
|
routeId: props.routeId,
|
||||||
|
workOrderId: props.workOrderId,
|
||||||
|
})
|
||||||
|
.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑任务 */
|
||||||
|
function handleEdit(row: MesProTaskApi.Task) {
|
||||||
|
taskFormModalApi.setData({ id: row.id }).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除任务 */
|
||||||
|
async function handleDelete(row: MesProTaskApi.Task) {
|
||||||
|
const hideLoading = message.loading({
|
||||||
|
content: $t('ui.actionMessage.deleting', [row.code]),
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await deleteTask(row.id!);
|
||||||
|
message.success($t('ui.actionMessage.deleteSuccess', [row.code]));
|
||||||
|
handleRefresh();
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
gridOptions: {
|
||||||
|
columns: useTaskGridColumns(editable.value),
|
||||||
|
height: 360,
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async () => {
|
||||||
|
if (!props.workOrderId) {
|
||||||
|
return { list: [], total: 0 };
|
||||||
|
}
|
||||||
|
return await getTaskPage({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
processId: props.processId,
|
||||||
|
routeId: props.routeId,
|
||||||
|
workOrderId: props.workOrderId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<MesProTaskApi.Task>,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 工序步骤切换时重新加载当前工序的任务
|
||||||
|
watch(
|
||||||
|
() => props.processId,
|
||||||
|
() => handleRefresh(),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<TaskFormModal @success="handleRefresh" />
|
||||||
|
<Grid table-title="生产任务">
|
||||||
|
<template v-if="editable" #toolbar-tools>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '新增任务',
|
||||||
|
type: 'primary',
|
||||||
|
icon: ACTION_ICON.ADD,
|
||||||
|
auth: ['mes:pro-task:create'],
|
||||||
|
onClick: handleCreate,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #colorCode="{ row }">
|
||||||
|
<div
|
||||||
|
class="mx-auto size-5 rounded"
|
||||||
|
:style="{ background: row.colorCode || '#00AEF3' }"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: $t('common.edit'),
|
||||||
|
type: 'link',
|
||||||
|
icon: ACTION_ICON.EDIT,
|
||||||
|
auth: ['mes:pro-task:update'],
|
||||||
|
onClick: handleEdit.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('common.delete'),
|
||||||
|
type: 'link',
|
||||||
|
danger: true,
|
||||||
|
icon: ACTION_ICON.DELETE,
|
||||||
|
auth: ['mes:pro-task:delete'],
|
||||||
|
popConfirm: {
|
||||||
|
title: $t('ui.actionMessage.deleteConfirm', [row.code]),
|
||||||
|
confirm: handleDelete.bind(null, row),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -53,6 +53,12 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
||||||
warehouseId: values.warehouseId,
|
warehouseId: values.warehouseId,
|
||||||
placeholder: '请选择库区',
|
placeholder: '请选择库区',
|
||||||
}),
|
}),
|
||||||
|
// 仓库切换时清空库区,避免旧库区条件残留
|
||||||
|
trigger: (values, formApi) => {
|
||||||
|
if (values.locationId !== undefined) {
|
||||||
|
void formApi.setFieldValue('locationId', undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -191,6 +197,12 @@ export function useSelectGridFormSchema(): VbenFormSchema[] {
|
||||||
warehouseId: values.warehouseId,
|
warehouseId: values.warehouseId,
|
||||||
placeholder: '请选择库区',
|
placeholder: '请选择库区',
|
||||||
}),
|
}),
|
||||||
|
// 仓库切换时清空库区
|
||||||
|
trigger: (values, formApi) => {
|
||||||
|
if (values.locationId !== undefined) {
|
||||||
|
void formApi.setFieldValue('locationId', undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -198,11 +210,17 @@ export function useSelectGridFormSchema(): VbenFormSchema[] {
|
||||||
label: '库位',
|
label: '库位',
|
||||||
component: markRaw(WmWarehouseAreaSelect),
|
component: markRaw(WmWarehouseAreaSelect),
|
||||||
dependencies: {
|
dependencies: {
|
||||||
triggerFields: ['locationId'],
|
triggerFields: ['warehouseId', 'locationId'],
|
||||||
componentProps: (values) => ({
|
componentProps: (values) => ({
|
||||||
locationId: values.locationId,
|
locationId: values.locationId,
|
||||||
placeholder: '请选择库位',
|
placeholder: '请选择库位',
|
||||||
}),
|
}),
|
||||||
|
// 仓库或库区切换时清空库位
|
||||||
|
trigger: (values, formApi) => {
|
||||||
|
if (values.areaId !== undefined) {
|
||||||
|
void formApi.setFieldValue('areaId', undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ const routes: RouteRecordRaw[] = [
|
||||||
component: () => import('#/views/mes/wm/barcode/config/index.vue'),
|
component: () => import('#/views/mes/wm/barcode/config/index.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'pro/task/edit',
|
path: 'pro/task/gantt-edit',
|
||||||
name: 'MesProTaskGanttEdit',
|
name: 'MesProTaskGanttEdit',
|
||||||
meta: {
|
meta: {
|
||||||
title: '甘特图编辑',
|
title: '甘特图编辑',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { gantt } from 'dhtmlx-gantt';
|
||||||
|
|
||||||
|
import { BarcodeBizTypeEnum } from '#/views/mes/utils/constants';
|
||||||
|
|
||||||
|
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>
|
||||||
|
/* 今天标记线 */
|
||||||
|
.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>
|
||||||
|
|
@ -47,23 +47,19 @@ const showClear = computed(() => // 是否显示清空图标
|
||||||
props.clearable &&
|
props.clearable &&
|
||||||
!props.disabled &&
|
!props.disabled &&
|
||||||
hovering.value &&
|
hovering.value &&
|
||||||
props.modelValue !== undefined,
|
props.modelValue != null,
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 根据任务编号回显选择器 */
|
/** 根据任务编号回显选择器 */
|
||||||
async function resolveItemById(id: number | undefined) {
|
async function resolveItemById(id: number | undefined) {
|
||||||
if (id === undefined) {
|
if (id == null) {
|
||||||
selectedItem.value = undefined;
|
selectedItem.value = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedItem.value?.id === id) {
|
if (selectedItem.value?.id === id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
selectedItem.value = await getTask(id);
|
||||||
selectedItem.value = await getTask(id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ProTaskSelect] resolveItemById failed:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -92,8 +88,7 @@ function handleClick(event: MouseEvent) {
|
||||||
clearSelected();
|
clearSelected();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectedIds =
|
const selectedIds = props.modelValue == null ? [] : [props.modelValue];
|
||||||
props.modelValue === undefined ? [] : [props.modelValue];
|
|
||||||
dialogRef.value?.open(selectedIds, {
|
dialogRef.value?.open(selectedIds, {
|
||||||
multiple: false,
|
multiple: false,
|
||||||
workOrderId: props.workOrderId,
|
workOrderId: props.workOrderId,
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,15 @@ export function useScheduleFormSchema(): VbenFormSchema[] {
|
||||||
valueFormat: 'x',
|
valueFormat: 'x',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '工单状态',
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
disabled: true,
|
||||||
|
options: getDictOptions(DICT_TYPE.MES_PRO_WORK_ORDER_STATUS, 'number'),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'remark',
|
fieldName: 'remark',
|
||||||
label: '备注',
|
label: '备注',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { DocAlert, Page } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { ElBadge, ElButton, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { getGanttTaskList, updateTask } from '#/api/mes/pro/task';
|
||||||
|
|
||||||
|
import GanttChart from '../components/gantt-chart.vue';
|
||||||
|
|
||||||
|
const submitting = ref(false); // 批量保存按钮加载态
|
||||||
|
const taskList = ref<any[]>([]); // 甘特图任务数据
|
||||||
|
const pendingChanges = ref(new Map<number, any>()); // 待保存的修改 Map<taskId, changeData>
|
||||||
|
|
||||||
|
const pendingCount = computed(() => pendingChanges.value.size); // 待保存数量
|
||||||
|
const ganttHeight = computed(() => window.innerHeight - 220); // 甘特图高度
|
||||||
|
|
||||||
|
/** 加载甘特图数据 */
|
||||||
|
async function loadGanttData() {
|
||||||
|
taskList.value = await getGanttTaskList({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务编辑回调:缓存待保存的修改 */
|
||||||
|
function handleTaskUpdate(change: any) {
|
||||||
|
pendingChanges.value.set(change.id, change);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量保存 */
|
||||||
|
async function handleSave() {
|
||||||
|
if (pendingChanges.value.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const changes = [...pendingChanges.value.values()];
|
||||||
|
await Promise.all(
|
||||||
|
changes.map((change) =>
|
||||||
|
updateTask({
|
||||||
|
duration: change.duration,
|
||||||
|
endTime: change.endTime,
|
||||||
|
id: change.id,
|
||||||
|
startTime: change.startTime,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ElMessage.success(`已保存 ${changes.length} 条修改`);
|
||||||
|
pendingChanges.value = new Map();
|
||||||
|
await loadGanttData();
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新 */
|
||||||
|
async function handleRefresh() {
|
||||||
|
pendingChanges.value = new Map();
|
||||||
|
await loadGanttData();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadGanttData);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<template #doc>
|
||||||
|
<DocAlert
|
||||||
|
title="【生产】生产排产、工序流转卡"
|
||||||
|
url="https://doc.iocoder.cn/mes/pro/schedule-card/"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
可直接拖拽/拉伸任务条,或双击编辑开始时间和时长,修改后点击「批量保存」
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<ElBadge :value="pendingCount" :hidden="pendingCount === 0">
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
:disabled="pendingCount === 0"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
批量保存
|
||||||
|
</ElButton>
|
||||||
|
</ElBadge>
|
||||||
|
<ElButton @click="handleRefresh">刷新</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GanttChart
|
||||||
|
:height="ganttHeight"
|
||||||
|
:readonly="false"
|
||||||
|
:tasks="taskList"
|
||||||
|
@task-update="handleTaskUpdate"
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
|
||||||
|
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { ElButton, ElCard } from 'element-plus';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { getGanttTaskList } from '#/api/mes/pro/task';
|
||||||
|
import { getWorkOrderPage } from '#/api/mes/pro/workorder';
|
||||||
|
import {
|
||||||
|
MesProWorkOrderStatusEnum,
|
||||||
|
MesProWorkOrderTypeEnum,
|
||||||
|
} from '#/views/mes/utils/constants';
|
||||||
|
|
||||||
|
import GanttChart from './components/gantt-chart.vue';
|
||||||
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
import ScheduleForm from './modules/schedule-form.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const ganttTasks = ref<any[]>([]); // 甘特图预览数据
|
||||||
|
|
||||||
|
const [ScheduleModal, scheduleModalApi] = useVbenModal({
|
||||||
|
connectedComponent: ScheduleForm,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function handleRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
loadGanttPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载甘特图预览数据 */
|
||||||
|
async function loadGanttPreview() {
|
||||||
|
ganttTasks.value = await getGanttTaskList({
|
||||||
|
status: MesProWorkOrderStatusEnum.CONFIRMED,
|
||||||
|
type: MesProWorkOrderTypeEnum.SELF,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看工单详情 */
|
||||||
|
function handleDetail(row: MesProWorkOrderApi.WorkOrder) {
|
||||||
|
scheduleModalApi.setData({ formType: 'detail', id: row.id }).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排产 */
|
||||||
|
function handleSchedule(row: MesProWorkOrderApi.WorkOrder) {
|
||||||
|
scheduleModalApi.setData({ formType: 'schedule', id: row.id }).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开甘特图编辑页面 */
|
||||||
|
function handleGanttEdit() {
|
||||||
|
router.push({ name: 'MesProTaskGanttEdit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: useGridFormSchema(),
|
||||||
|
},
|
||||||
|
gridOptions: {
|
||||||
|
columns: useGridColumns(),
|
||||||
|
keepSource: true,
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getWorkOrderPage({
|
||||||
|
pageNo: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
status: MesProWorkOrderStatusEnum.CONFIRMED,
|
||||||
|
type: MesProWorkOrderTypeEnum.SELF,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
search: true,
|
||||||
|
},
|
||||||
|
treeConfig: {
|
||||||
|
parentField: 'parentId',
|
||||||
|
rowField: 'id',
|
||||||
|
transform: true,
|
||||||
|
expandAll: true,
|
||||||
|
reserve: true,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<MesProWorkOrderApi.WorkOrder>,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(loadGanttPreview);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<template #doc>
|
||||||
|
<DocAlert
|
||||||
|
title="【生产】生产排产、工序流转卡"
|
||||||
|
url="https://doc.iocoder.cn/mes/pro/schedule-card/"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ScheduleModal @success="handleRefresh" />
|
||||||
|
|
||||||
|
<!-- 排产甘特图预览 -->
|
||||||
|
<ElCard class="mb-4" header="排产甘特图">
|
||||||
|
<GanttChart :height="350" :readonly="true" :tasks="ganttTasks" />
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<Grid table-title="待排产工单">
|
||||||
|
<template #toolbar-tools>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '甘特图编辑',
|
||||||
|
type: 'primary',
|
||||||
|
icon: ACTION_ICON.EDIT,
|
||||||
|
onClick: handleGanttEdit,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #code="{ row }">
|
||||||
|
<ElButton link type="primary" @click="handleDetail(row)">
|
||||||
|
{{ row.code }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '排产',
|
||||||
|
type: 'primary',
|
||||||
|
link: true,
|
||||||
|
auth: ['mes:pro-task:create'],
|
||||||
|
ifShow: row.status === MesProWorkOrderStatusEnum.CONFIRMED,
|
||||||
|
onClick: handleSchedule.bind(null, row),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
|
||||||
|
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { ElButton, ElCard, ElMessage, ElPopconfirm, ElStep, ElSteps } from 'element-plus';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import { getRouteProcessListByProduct } from '#/api/mes/pro/route/process';
|
||||||
|
import { finishWorkOrder, getWorkOrder } from '#/api/mes/pro/workorder';
|
||||||
|
|
||||||
|
import { useScheduleFormSchema } from '../data';
|
||||||
|
import TaskList from './task-list.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
const formType = ref<'detail' | 'schedule'>('schedule');
|
||||||
|
const workOrder = ref<MesProWorkOrderApi.WorkOrder>(); // 当前工单详情
|
||||||
|
const routeProcessList = ref<MesProRouteProcessApi.RouteProcess[]>([]); // 工艺路线工序列表
|
||||||
|
const activeProcessStep = ref(0); // 当前工序步骤索引
|
||||||
|
const currentRouteId = ref(0); // 当前工艺路线编号
|
||||||
|
|
||||||
|
const isReadonly = computed(() => formType.value === 'detail'); // 详情态只读
|
||||||
|
const getTitle = computed(() =>
|
||||||
|
formType.value === 'detail' ? '工单详情' : '生产排产',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-1',
|
||||||
|
labelWidth: 100,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useScheduleFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
wrapperClass: 'grid-cols-3',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 加载工艺路线工序列表 */
|
||||||
|
async function loadRouteProcesses(productId: number) {
|
||||||
|
const processes = await getRouteProcessListByProduct(productId);
|
||||||
|
if (!processes || processes.length === 0) {
|
||||||
|
ElMessage.warning('当前产品未配置工艺路线,请先在工艺路线中维护');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentRouteId.value = processes[0]!.routeId ?? 0;
|
||||||
|
routeProcessList.value = [...processes].toSorted(
|
||||||
|
(a, b) => (a.sort ?? 0) - (b.sort ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完成工单 */
|
||||||
|
async function handleFinish() {
|
||||||
|
if (!workOrder.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
await finishWorkOrder(workOrder.value.id);
|
||||||
|
ElMessage.success('工单已完成');
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
showConfirmButton: false,
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
workOrder.value = undefined;
|
||||||
|
routeProcessList.value = [];
|
||||||
|
activeProcessStep.value = 0;
|
||||||
|
currentRouteId.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 加载数据
|
||||||
|
const data = modalApi.getData<{ formType: 'detail' | 'schedule'; id: number }>();
|
||||||
|
formType.value = data.formType;
|
||||||
|
activeProcessStep.value = 0;
|
||||||
|
routeProcessList.value = [];
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
workOrder.value = await getWorkOrder(data.id);
|
||||||
|
// 设置到 values
|
||||||
|
await formApi.setValues(workOrder.value);
|
||||||
|
if (workOrder.value.productId) {
|
||||||
|
await loadRouteProcesses(workOrder.value.productId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :title="getTitle" class="w-4/5">
|
||||||
|
<Form class="mx-4" />
|
||||||
|
<!-- 工序步骤导航 + 当前工序任务列表 -->
|
||||||
|
<template v-if="routeProcessList.length > 0 && workOrder?.id">
|
||||||
|
<ElSteps
|
||||||
|
:active="activeProcessStep"
|
||||||
|
align-center
|
||||||
|
class="my-4 px-4"
|
||||||
|
simple
|
||||||
|
>
|
||||||
|
<ElStep
|
||||||
|
v-for="(rp, index) in routeProcessList"
|
||||||
|
:key="rp.processId"
|
||||||
|
class="cursor-pointer"
|
||||||
|
:title="rp.processName"
|
||||||
|
@click="activeProcessStep = index"
|
||||||
|
/>
|
||||||
|
</ElSteps>
|
||||||
|
<ElCard
|
||||||
|
v-for="(rp, index) in routeProcessList"
|
||||||
|
v-show="activeProcessStep === index"
|
||||||
|
:key="rp.processId"
|
||||||
|
class="mx-4"
|
||||||
|
shadow="never"
|
||||||
|
>
|
||||||
|
<TaskList
|
||||||
|
:color-code="rp.colorCode"
|
||||||
|
:disabled="isReadonly"
|
||||||
|
:item-id="workOrder.productId"
|
||||||
|
:process-id="rp.processId!"
|
||||||
|
:route-id="currentRouteId"
|
||||||
|
:work-order-id="workOrder.id!"
|
||||||
|
/>
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
<template #prepend-footer>
|
||||||
|
<div class="flex flex-auto items-center gap-2">
|
||||||
|
<ElPopconfirm
|
||||||
|
v-if="formType === 'schedule'"
|
||||||
|
title="确认要完成该工单吗?完成后工单下所有任务将标记为已完成。"
|
||||||
|
width="320"
|
||||||
|
@confirm="handleFinish"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<ElButton type="primary">完成</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElPopconfirm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MesProTaskApi } from '#/api/mes/pro/task';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import { createTask, getTask, updateTask } from '#/api/mes/pro/task';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useTaskFormSchema } from '../data';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
const formData = ref<MesProTaskApi.Task>();
|
||||||
|
const context = ref<{
|
||||||
|
colorCode?: string;
|
||||||
|
itemId?: number;
|
||||||
|
processId?: number;
|
||||||
|
routeId?: number;
|
||||||
|
workOrderId?: number;
|
||||||
|
}>({}); // 新增任务时的工单 / 工序上下文
|
||||||
|
|
||||||
|
const getTitle = computed(() => {
|
||||||
|
return formData.value?.id
|
||||||
|
? $t('ui.actionTitle.edit', ['生产任务'])
|
||||||
|
: $t('ui.actionTitle.create', ['生产任务']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-1',
|
||||||
|
labelWidth: 100,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: [],
|
||||||
|
showDefaultActions: false,
|
||||||
|
wrapperClass: 'grid-cols-3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
async onConfirm() {
|
||||||
|
const { valid } = await formApi.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
// 提交表单
|
||||||
|
const data = (await formApi.getValues()) as MesProTaskApi.Task;
|
||||||
|
try {
|
||||||
|
await (formData.value?.id
|
||||||
|
? updateTask({ ...formData.value, ...data })
|
||||||
|
: createTask({ ...context.value, ...data }));
|
||||||
|
// 关闭并提示
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
formData.value = undefined;
|
||||||
|
context.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formApi.setState({ schema: useTaskFormSchema(formApi) });
|
||||||
|
// 加载数据
|
||||||
|
const data = modalApi.getData<{
|
||||||
|
colorCode?: string;
|
||||||
|
id?: number;
|
||||||
|
itemId?: number;
|
||||||
|
processId?: number;
|
||||||
|
routeId?: number;
|
||||||
|
workOrderId?: number;
|
||||||
|
}>();
|
||||||
|
if (data?.id) {
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
formData.value = await getTask(data.id);
|
||||||
|
// 设置到 values
|
||||||
|
await formApi.setValues(formData.value);
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.value = {
|
||||||
|
colorCode: data.colorCode,
|
||||||
|
itemId: data.itemId,
|
||||||
|
processId: data.processId,
|
||||||
|
routeId: data.routeId,
|
||||||
|
workOrderId: data.workOrderId,
|
||||||
|
};
|
||||||
|
await formApi.setValues({
|
||||||
|
colorCode: data.colorCode || '#00AEF3',
|
||||||
|
duration: 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :title="getTitle" class="w-3/5">
|
||||||
|
<Form class="mx-4" />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { MesProTaskApi } from '#/api/mes/pro/task';
|
||||||
|
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { ElLoading, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { deleteTask, getTaskPage } from '#/api/mes/pro/task';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useTaskGridColumns } from '../data';
|
||||||
|
import TaskForm from './task-form.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
colorCode?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
itemId?: number;
|
||||||
|
processId: number;
|
||||||
|
routeId: number;
|
||||||
|
workOrderId: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const editable = computed(() => !props.disabled); // 是否可维护任务
|
||||||
|
|
||||||
|
const [TaskFormModal, taskFormModalApi] = useVbenModal({
|
||||||
|
connectedComponent: TaskForm,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function handleRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增任务 */
|
||||||
|
function handleCreate() {
|
||||||
|
taskFormModalApi
|
||||||
|
.setData({
|
||||||
|
colorCode: props.colorCode,
|
||||||
|
itemId: props.itemId,
|
||||||
|
processId: props.processId,
|
||||||
|
routeId: props.routeId,
|
||||||
|
workOrderId: props.workOrderId,
|
||||||
|
})
|
||||||
|
.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑任务 */
|
||||||
|
function handleEdit(row: MesProTaskApi.Task) {
|
||||||
|
taskFormModalApi.setData({ id: row.id }).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除任务 */
|
||||||
|
async function handleDelete(row: MesProTaskApi.Task) {
|
||||||
|
const loadingInstance = ElLoading.service({
|
||||||
|
text: $t('ui.actionMessage.deleting', [row.code]),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await deleteTask(row.id!);
|
||||||
|
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.code]));
|
||||||
|
handleRefresh();
|
||||||
|
} finally {
|
||||||
|
loadingInstance.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
gridOptions: {
|
||||||
|
columns: useTaskGridColumns(editable.value),
|
||||||
|
height: 360,
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async () => {
|
||||||
|
if (!props.workOrderId) {
|
||||||
|
return { list: [], total: 0 };
|
||||||
|
}
|
||||||
|
return await getTaskPage({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
processId: props.processId,
|
||||||
|
routeId: props.routeId,
|
||||||
|
workOrderId: props.workOrderId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<MesProTaskApi.Task>,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 工序步骤切换时重新加载当前工序的任务
|
||||||
|
watch(
|
||||||
|
() => props.processId,
|
||||||
|
() => handleRefresh(),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<TaskFormModal @success="handleRefresh" />
|
||||||
|
<Grid table-title="生产任务">
|
||||||
|
<template v-if="editable" #toolbar-tools>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '新增任务',
|
||||||
|
type: 'primary',
|
||||||
|
icon: ACTION_ICON.ADD,
|
||||||
|
auth: ['mes:pro-task:create'],
|
||||||
|
onClick: handleCreate,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #colorCode="{ row }">
|
||||||
|
<div
|
||||||
|
class="mx-auto size-5 rounded"
|
||||||
|
:style="{ background: row.colorCode || '#00AEF3' }"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: $t('common.edit'),
|
||||||
|
type: 'primary',
|
||||||
|
link: true,
|
||||||
|
icon: ACTION_ICON.EDIT,
|
||||||
|
auth: ['mes:pro-task:update'],
|
||||||
|
onClick: handleEdit.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('common.delete'),
|
||||||
|
type: 'danger',
|
||||||
|
link: true,
|
||||||
|
icon: ACTION_ICON.DELETE,
|
||||||
|
auth: ['mes:pro-task:delete'],
|
||||||
|
popConfirm: {
|
||||||
|
title: $t('ui.actionMessage.deleteConfirm', [row.code]),
|
||||||
|
confirm: handleDelete.bind(null, row),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -53,6 +53,12 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
||||||
placeholder: '请选择库区',
|
placeholder: '请选择库区',
|
||||||
warehouseId: values.warehouseId,
|
warehouseId: values.warehouseId,
|
||||||
}),
|
}),
|
||||||
|
// 仓库切换时清空库区,避免旧库区条件残留
|
||||||
|
trigger: (values, formApi) => {
|
||||||
|
if (values.locationId !== undefined) {
|
||||||
|
void formApi.setFieldValue('locationId', undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -191,6 +197,12 @@ export function useSelectGridFormSchema(): VbenFormSchema[] {
|
||||||
placeholder: '请选择库区',
|
placeholder: '请选择库区',
|
||||||
warehouseId: values.warehouseId,
|
warehouseId: values.warehouseId,
|
||||||
}),
|
}),
|
||||||
|
// 仓库切换时清空库区
|
||||||
|
trigger: (values, formApi) => {
|
||||||
|
if (values.locationId !== undefined) {
|
||||||
|
void formApi.setFieldValue('locationId', undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -198,11 +210,17 @@ export function useSelectGridFormSchema(): VbenFormSchema[] {
|
||||||
label: '库位',
|
label: '库位',
|
||||||
component: markRaw(WmWarehouseAreaSelect),
|
component: markRaw(WmWarehouseAreaSelect),
|
||||||
dependencies: {
|
dependencies: {
|
||||||
triggerFields: ['locationId'],
|
triggerFields: ['warehouseId', 'locationId'],
|
||||||
componentProps: (values) => ({
|
componentProps: (values) => ({
|
||||||
locationId: values.locationId,
|
locationId: values.locationId,
|
||||||
placeholder: '请选择库位',
|
placeholder: '请选择库位',
|
||||||
}),
|
}),
|
||||||
|
// 仓库或库区切换时清空库位
|
||||||
|
trigger: (values, formApi) => {
|
||||||
|
if (values.areaId !== undefined) {
|
||||||
|
void formApi.setFieldValue('areaId', undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue