feat(mes): 迁移 work task 功能

pull/350/head
YunaiV 2026-05-30 10:37:43 +08:00
parent 7bf65041f9
commit be213b6b31
20 changed files with 1895 additions and 22 deletions

View File

@ -40,7 +40,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/mes/wm/barcode/config/index.vue'),
},
{
path: 'pro/task/edit',
path: 'pro/task/gantt-edit',
name: 'MesProTaskGanttEdit',
meta: {
title: '甘特图编辑',

View File

@ -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>

View File

@ -47,23 +47,19 @@ const showClear = computed(() => // 是否显示清空图标
props.allowClear &&
!props.disabled &&
hovering.value &&
props.modelValue !== undefined,
props.modelValue != null,
);
/** 根据任务编号回显选择器 */
async function resolveItemById(id: number | undefined) {
if (id === undefined) {
if (id == null) {
selectedItem.value = undefined;
return;
}
if (selectedItem.value?.id === id) {
return;
}
try {
selectedItem.value = await getTask(id);
} catch (error) {
console.error('[ProTaskSelect] resolveItemById failed:', error);
}
selectedItem.value = await getTask(id);
}
watch(
@ -92,8 +88,7 @@ function handleClick(event: MouseEvent) {
clearSelected();
return;
}
const selectedIds =
props.modelValue === undefined ? [] : [props.modelValue];
const selectedIds = props.modelValue == null ? [] : [props.modelValue];
dialogRef.value?.open(selectedIds, {
multiple: false,
workOrderId: props.workOrderId,

View File

@ -279,6 +279,15 @@ export function useScheduleFormSchema(): VbenFormSchema[] {
valueFormat: 'x',
},
},
{
fieldName: 'status',
label: '工单状态',
component: 'Select',
componentProps: {
disabled: true,
options: getDictOptions(DICT_TYPE.MES_PRO_WORK_ORDER_STATUS, 'number'),
},
},
{
fieldName: 'remark',
label: '备注',

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -53,6 +53,12 @@ export function useGridFormSchema(): VbenFormSchema[] {
warehouseId: values.warehouseId,
placeholder: '请选择库区',
}),
// 仓库切换时清空库区,避免旧库区条件残留
trigger: (values, formApi) => {
if (values.locationId !== undefined) {
void formApi.setFieldValue('locationId', undefined);
}
},
},
},
{
@ -191,6 +197,12 @@ export function useSelectGridFormSchema(): VbenFormSchema[] {
warehouseId: values.warehouseId,
placeholder: '请选择库区',
}),
// 仓库切换时清空库区
trigger: (values, formApi) => {
if (values.locationId !== undefined) {
void formApi.setFieldValue('locationId', undefined);
}
},
},
},
{
@ -198,11 +210,17 @@ export function useSelectGridFormSchema(): VbenFormSchema[] {
label: '库位',
component: markRaw(WmWarehouseAreaSelect),
dependencies: {
triggerFields: ['locationId'],
triggerFields: ['warehouseId', 'locationId'],
componentProps: (values) => ({
locationId: values.locationId,
placeholder: '请选择库位',
}),
// 仓库或库区切换时清空库位
trigger: (values, formApi) => {
if (values.areaId !== undefined) {
void formApi.setFieldValue('areaId', undefined);
}
},
},
},
];

View File

@ -40,7 +40,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/mes/wm/barcode/config/index.vue'),
},
{
path: 'pro/task/edit',
path: 'pro/task/gantt-edit',
name: 'MesProTaskGanttEdit',
meta: {
title: '甘特图编辑',

View File

@ -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>

View File

@ -47,23 +47,19 @@ const showClear = computed(() => // 是否显示清空图标
props.clearable &&
!props.disabled &&
hovering.value &&
props.modelValue !== undefined,
props.modelValue != null,
);
/** 根据任务编号回显选择器 */
async function resolveItemById(id: number | undefined) {
if (id === undefined) {
if (id == null) {
selectedItem.value = undefined;
return;
}
if (selectedItem.value?.id === id) {
return;
}
try {
selectedItem.value = await getTask(id);
} catch (error) {
console.error('[ProTaskSelect] resolveItemById failed:', error);
}
selectedItem.value = await getTask(id);
}
watch(
@ -92,8 +88,7 @@ function handleClick(event: MouseEvent) {
clearSelected();
return;
}
const selectedIds =
props.modelValue === undefined ? [] : [props.modelValue];
const selectedIds = props.modelValue == null ? [] : [props.modelValue];
dialogRef.value?.open(selectedIds, {
multiple: false,
workOrderId: props.workOrderId,

View File

@ -280,6 +280,15 @@ export function useScheduleFormSchema(): VbenFormSchema[] {
valueFormat: 'x',
},
},
{
fieldName: 'status',
label: '工单状态',
component: 'Select',
componentProps: {
disabled: true,
options: getDictOptions(DICT_TYPE.MES_PRO_WORK_ORDER_STATUS, 'number'),
},
},
{
fieldName: 'remark',
label: '备注',

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -53,6 +53,12 @@ export function useGridFormSchema(): VbenFormSchema[] {
placeholder: '请选择库区',
warehouseId: values.warehouseId,
}),
// 仓库切换时清空库区,避免旧库区条件残留
trigger: (values, formApi) => {
if (values.locationId !== undefined) {
void formApi.setFieldValue('locationId', undefined);
}
},
},
},
{
@ -191,6 +197,12 @@ export function useSelectGridFormSchema(): VbenFormSchema[] {
placeholder: '请选择库区',
warehouseId: values.warehouseId,
}),
// 仓库切换时清空库区
trigger: (values, formApi) => {
if (values.locationId !== undefined) {
void formApi.setFieldValue('locationId', undefined);
}
},
},
},
{
@ -198,11 +210,17 @@ export function useSelectGridFormSchema(): VbenFormSchema[] {
label: '库位',
component: markRaw(WmWarehouseAreaSelect),
dependencies: {
triggerFields: ['locationId'],
triggerFields: ['warehouseId', 'locationId'],
componentProps: (values) => ({
locationId: values.locationId,
placeholder: '请选择库位',
}),
// 仓库或库区切换时清空库位
trigger: (values, formApi) => {
if (values.areaId !== undefined) {
void formApi.setFieldValue('areaId', undefined);
}
},
},
},
];