feat(mes): 优化生产订单与排产任务列表显示,新增启停工及校验逻辑
1. 生产订单列表展示优化 (WorkOrder)
- 新增「生产状态」列,全局注册 `ORDER_PRODUCTION_STATUS` 常量并绑定系统动态字典。
- 彻底重构「工序生产进度」单元格显示:弃用臃肿的 Steps 组件,改为极简横向布局(彩色状态圆点 + 进度百分比),解决表格行高被强行撑开及内容截断问题,支持横向滚动。
- 优化「完成进度」列宽及展示样式,将百分比数值外置同行显示,更加直观。
2. 排产任务列表改造 (Task)
- 顶部表头重构,由单一标题重构为「全部 / 未完成 / 已完成」的 Tabs 标签页切换结构。
- 重写数据过滤逻辑,剥离写死的 status 查询,改为使用 `scheduleStatus` 字段动态响应 Tabs 切换 (0: 未完成, 1: 已完成)。
3. 工单业务逻辑增强与状态流转
- 落实「工单查询逻辑设计方案」,优化底层的工单条件检索逻辑。
- 实现「添加工单启停功能」与「停工状态」,支持工单在执行过程中的暂停与恢复业务流转。
- 强化容错与防呆设计:操作「完成订单」时增加前置校验 `handleCheckFinish`,若生产状态为未完成,强制弹出二次确认 Modal,避免误操作。
# 相关讨论/参考方案
# - 添加工单启停功能
# - 停工状态功能设计方案
# - 工单查询逻辑设计方案
# - 工单完成进度显示优化
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace MesProTaskApi { |
| | | export interface PauseRecord { |
| | | id: number; |
| | | pauseTime: string; |
| | | pauseUserId: number; |
| | | pauseUserName: string; |
| | | reason: string; |
| | | remark?: string; |
| | | resumeTime?: string; |
| | | resumeUserId?: number; |
| | | resumeUserName?: string; |
| | | taskId: number; |
| | | } |
| | | |
| | | /** MES 生产任务 */ |
| | | export interface Task { |
| | | id?: number; |
| | |
| | | checkFlag?: boolean; // 是否质检(派生自工艺路线工序) |
| | | backflushFlag?: boolean; // 是否倒冲(派生自工艺路线工序) |
| | | remark?: string; // 备注 |
| | | pauseRecords?: PauseRecord[]; // 停工记录 |
| | | } |
| | | |
| | | /** MES 生产任务分页查询参数 */ |
| | |
| | | { params: { workstationId, quantity } }, |
| | | ); |
| | | } |
| | | |
| | | /** 生产任务停工请求参数 */ |
| | | export interface MesProTaskPauseReqVO { |
| | | id: number; |
| | | reason: string; |
| | | remark?: string; |
| | | } |
| | | |
| | | /** 生产任务停工 */ |
| | | export function pauseTask(data: MesProTaskPauseReqVO) { |
| | | return requestClient.put('/mes/pro/task/pause', data); |
| | | } |
| | | |
| | | /** 生产任务复工 */ |
| | | export function resumeTask(data: { id: number }) { |
| | | return requestClient.put('/mes/pro/task/resume', data); |
| | | } |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | |
| | | finishDate?: number; // 完成时间 |
| | | cancelDate?: number; // 取消时间 |
| | | status?: number; // 订单状态 |
| | | scheduleStatus?: number; // 排产状态 |
| | | productionStatus?: number; // 生产状态 |
| | | processProgressList?: ProcessProgress[]; // 工序生产进度 |
| | | completionProgress?: number; // 完成进度 |
| | | remark?: string; // 备注 |
| | | createTime?: number; // 创建时间 |
| | | } |
| | |
| | | ); |
| | | } |
| | | |
| | | /** 获得生产工单调度状态分页 */ |
| | | export function getWorkOrderSchedulePage(params: MesProWorkOrderApi.PageParams) { |
| | | return requestClient.get<PageResult<MesProWorkOrderApi.WorkOrder>>( |
| | | '/mes/pro/work-order/schedule-page', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** 查询生产订单详情 */ |
| | | export function getWorkOrder(id: number) { |
| | | return requestClient.get<MesProWorkOrderApi.WorkOrder>( |
| | |
| | | MES_DV_CHECK_RESULT: 'mes_dv_check_result', // MES 点检结果 |
| | | MES_PRO_LINK_TYPE: 'mes_pro_link_type', // MES 工序关系类型 |
| | | MES_PRO_WORK_ORDER_STATUS: 'mes_pro_work_order_status', // MES 生产工单状态 |
| | | ORDER_PRODUCTION_STATUS: 'order_production_status', // 生产订单状态 |
| | | MES_PRO_WORK_ORDER_TYPE: 'mes_pro_work_order_type', // MES 工单类型 |
| | | MES_PRO_WORK_ORDER_SOURCE_TYPE: 'mes_pro_work_order_source_type', // MES 工单来源类型 |
| | | MES_PRO_TASK_STATUS: 'mes_pro_task_status', // MES 生产任务状态 |
| | |
| | | width: 80, |
| | | }, |
| | | { |
| | | field: 'status', |
| | | title: '订单状态', |
| | | width: 100, |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.MES_PRO_WORK_ORDER_STATUS }, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'productionStatus', |
| | | title: '生产状态', |
| | | width: 100, |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.ORDER_PRODUCTION_STATUS }, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'quantity', |
| | | title: '订单数量', |
| | | width: 100, |
| | | }, |
| | | { |
| | | field: 'quantityChanged', |
| | | title: '调整数量', |
| | | width: 100, |
| | | }, |
| | | // { |
| | | // field: 'quantityChanged', |
| | | // title: '调整数量', |
| | | // width: 100, |
| | | // }, |
| | | { |
| | | field: 'quantityProduced', |
| | | title: '已生产数量', |
| | |
| | | width: 120, |
| | | formatter: 'formatDate', |
| | | }, |
| | | { |
| | | field: 'status', |
| | | title: '排产状态', |
| | | width: 100, |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.MES_PRO_WORK_ORDER_STATUS }, |
| | | }, |
| | | }, |
| | | |
| | | { |
| | | title: '操作', |
| | | width: 160, |
| | |
| | | <script lang="ts" setup> |
| | | <script lang="ts" setup> |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder'; |
| | | import {getWorkOrderSchedulePage, type MesProWorkOrderApi} from '#/api/mes/pro/workorder'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | |
| | | MesProWorkOrderTypeEnum, |
| | | } from '@vben/constants'; |
| | | |
| | | import { Button, Card, message } from 'ant-design-vue'; |
| | | import { Button, Card, message, Tabs } from 'ant-design-vue'; |
| | | |
| | | import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { getGanttTaskList, previewAutoGenerate, autoGenerateTasks } from '#/api/mes/pro/task'; |
| | |
| | | const ganttTasks = ref<any[]>([]); // 甘特图预览数据 |
| | | const archiveVisible = ref(false); |
| | | const archiveProductCode = ref(''); |
| | | |
| | | const activeTab = ref('PENDING'); // 默认选中待排产 |
| | | |
| | | const [ScheduleModal, scheduleModalApi] = useVbenModal({ |
| | | connectedComponent: ScheduleForm, |
| | |
| | | proxyConfig: { |
| | | ajax: { |
| | | query: async ({ page }, formValues) => { |
| | | return await getWorkOrderPage({ |
| | | let scheduleStatus: number | undefined = undefined; |
| | | |
| | | if (activeTab.value === 'PENDING') { |
| | | scheduleStatus = 0; // 待排产 |
| | | } else if (activeTab.value === 'FINISHED') { |
| | | scheduleStatus = 1; // 已完成 |
| | | } |
| | | // activeTab === 'ALL' 时不传 |
| | | |
| | | return await getWorkOrderSchedulePage({ |
| | | pageNo: page.currentPage, |
| | | pageSize: page.pageSize, |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | scheduleStatus, |
| | | type: MesProWorkOrderTypeEnum.SELF, |
| | | ...formValues, |
| | | }); |
| | |
| | | <GanttChart :height="350" :readonly="true" :tasks="ganttTasks" /> |
| | | </Card> |
| | | |
| | | <Grid table-title="待排产订单"> |
| | | <Grid> |
| | | <template #table-title> |
| | | <div class="flex flex-col gap-2"> |
| | | <span class="text-base font-medium">生产订单列表</span> |
| | | <Tabs v-model:activeKey="activeTab" @change="handleRefresh" :tabBarStyle="{ margin: 0, borderBottom: 0 }"> |
| | | <Tabs.TabPane key="ALL" tab="全部" /> |
| | | <Tabs.TabPane key="PENDING" tab="未完成" /> |
| | | <Tabs.TabPane key="FINISHED" tab="已完成" /> |
| | | </Tabs> |
| | | </div> |
| | | </template> |
| | | <template #toolbar-tools> |
| | | <TableAction |
| | | :actions="[ |
| | |
| | | <script lang="ts" setup> |
| | | <script lang="ts" setup> |
| | | import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder'; |
| | | import type { MesProWorkOrderProcessApi } from '#/api/mes/pro/workorder/process'; |
| | | |
| | |
| | | message.warning('当前订单未配置工序,请先在工单中维护工序信息'); |
| | | return; |
| | | } |
| | | // 确定工艺路线ID:工单路线 → 产品路线 |
| | | let tempRouteId = 0; |
| | | if (workOrder.value?.routeId) { |
| | | tempRouteId = workOrder.value.routeId; |
| | | } else { |
| | | const processes = await getRouteProcessListByProduct(productId); |
| | | tempRouteId = processes?.[0]?.routeId ?? 0; |
| | | } |
| | | currentRouteId.value = tempRouteId; |
| | | |
| | | routeProcessList.value = [...woProcesses].toSorted( |
| | | (a, b) => (a.sort ?? 0) - (b.sort ?? 0), |
| | | ); |
| | | // 确定工艺路线ID:工单路线 → 产品路线 |
| | | if (workOrder.value?.routeId) { |
| | | currentRouteId.value = workOrder.value.routeId; |
| | | } else { |
| | | const processes = await getRouteProcessListByProduct(productId); |
| | | currentRouteId.value = processes?.[0]?.routeId ?? 0; |
| | | } |
| | | } |
| | | |
| | | /** 完成订单 */ |
| | |
| | | :title="rp.processName" |
| | | /> |
| | | </Steps> |
| | | <template v-for="(rp, index) in routeProcessList" :key="rp.processId"> |
| | | <Card |
| | | v-for="(rp, index) in routeProcessList" |
| | | v-show="activeProcessStep === index" |
| | | :key="rp.processId" |
| | | v-if="activeProcessStep === index" |
| | | class="mx-4" |
| | | > |
| | | <TaskList |
| | |
| | | /> |
| | | </Card> |
| | | </template> |
| | | </template> |
| | | <template #prepend-footer> |
| | | <div class="flex flex-auto items-center gap-2"> |
| | | <template v-if="formType === 'schedule'"> |
| | |
| | | () => props.processId, |
| | | () => handleRefresh(), |
| | | ); |
| | | |
| | | |
| | | </script> |
| | | |
| | | <template> |
| | |
| | | import type { VbenFormApi, VbenFormSchema } from '#/adapter/form'; |
| | | import type { VbenFormApi, VbenFormSchema } from '#/adapter/form'; |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder'; |
| | | import type { MesProWorkOrderBomApi } from '#/api/mes/pro/workorder/bom'; |
| | |
| | | }, |
| | | }, |
| | | { |
| | | field: 'productionStatus', |
| | | title: '生产状态', |
| | | width: 100, |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.ORDER_PRODUCTION_STATUS }, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'processProgressList', |
| | | title: '工序生产进度', |
| | | width: 350, |
| | | slots: { default: 'processProgressList' }, |
| | | }, |
| | | { |
| | | field: 'completionProgress', |
| | | title: '完成进度', |
| | | width: 200, |
| | | slots: { default: 'completionProgress' }, |
| | | }, |
| | | { |
| | | field: 'createTime', |
| | | title: '创建时间', |
| | | width: 180, |
| | |
| | | } from "@vben/constants"; |
| | | import { downloadFileFromBlobPart } from "@vben/utils"; |
| | | |
| | | import { Button, message } from "ant-design-vue"; |
| | | import { Button, message, Progress, Steps, Popover, Modal } from "ant-design-vue"; |
| | | |
| | | import { ACTION_ICON, TableAction, useVbenVxeGrid } from "#/adapter/vxe-table"; |
| | | import { |
| | |
| | | /** 完成订单 */ |
| | | function handleFinish(row: MesProWorkOrderApi.WorkOrder) { |
| | | formModalApi.setData({ formType: "finish", id: row.id }).open(); |
| | | } |
| | | |
| | | /** 完成订单前校验 */ |
| | | function handleCheckFinish(row: MesProWorkOrderApi.WorkOrder) { |
| | | if (row.productionStatus !== 2) { |
| | | Modal.confirm({ |
| | | title: '操作确认', |
| | | content: '该订单未完成,是否确认要继续完成?', |
| | | onOk() { |
| | | handleFinish(row); |
| | | }, |
| | | }); |
| | | } else { |
| | | handleFinish(row); |
| | | } |
| | | } |
| | | |
| | | /** 删除订单 */ |
| | |
| | | }, |
| | | } as VxeTableGridOptions<MesProWorkOrderApi.WorkOrder>, |
| | | }); |
| | | |
| | | /** 获取工序节点状态样式类 */ |
| | | function getProcessNodeStatus(percent: number) { |
| | | if (percent >= 100) return 'completed'; |
| | | if (percent > 0) return 'in-progress'; |
| | | return 'pending'; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | |
| | | @click="handleDetail(row)"> |
| | | {{ row.code }} |
| | | </Button> |
| | | </template> |
| | | <template #processProgressList="{ row }"> |
| | | <div v-if="row.processProgressList && row.processProgressList.length > 0" class="process-node-list"> |
| | | <div |
| | | v-for="(item, index) in row.processProgressList" |
| | | :key="index" |
| | | class="process-node-item" |
| | | > |
| | | <div class="process-node-dot" :class="getProcessNodeStatus(item.progressPercent || item.progress || 0)"></div> |
| | | <div class="process-node-info"> |
| | | <span class="process-node-name" :title="item.processName ">{{ item.processName }}</span> |
| | | <span class="process-node-percent">{{ item.progress || 0 }}%</span> |
| | | </div> |
| | | <div v-if="index !== row.processProgressList.length - 1" class="process-node-line"></div> |
| | | </div> |
| | | </div> |
| | | <span v-else>-</span> |
| | | </template> |
| | | <template #completionProgress="{ row }"> |
| | | <div style="display: flex; align-items: center; gap: 8px; width: 100%;"> |
| | | <Progress :percent="row.completionProgress || 0" :show-info="false" size="small" style="flex: 1; margin: 0;" /> |
| | | <span style="white-space: nowrap; min-width: 40px; text-align: left;">{{ row.completionProgress || 0 }}%</span> |
| | | </div> |
| | | </template> |
| | | <template #actions="{ row }"> |
| | | <TableAction :actions="[ |
| | |
| | | ifShow: |
| | | row.status === MesProWorkOrderStatusEnum.CONFIRMED && |
| | | !!row.quantityProduced, |
| | | onClick: handleFinish.bind(null, row), |
| | | onClick: handleCheckFinish.bind(null, row), |
| | | }, |
| | | { |
| | | label: '取消', |
| | |
| | | <ArchiveDetail ref="archiveDetailRef" /> |
| | | </Page> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .process-node-list { |
| | | display: flex; |
| | | align-items: center; |
| | | overflow-x: auto; |
| | | overflow-y: hidden; |
| | | max-width: 100%; |
| | | padding-bottom: 4px; |
| | | } |
| | | |
| | | /* Custom scrollbar to make it sleek */ |
| | | .process-node-list::-webkit-scrollbar { |
| | | height: 4px; |
| | | } |
| | | .process-node-list::-webkit-scrollbar-thumb { |
| | | background-color: #d9d9d9; |
| | | border-radius: 4px; |
| | | } |
| | | .process-node-list::-webkit-scrollbar-track { |
| | | background: transparent; |
| | | } |
| | | |
| | | .process-node-item { |
| | | display: flex; |
| | | align-items: center; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .process-node-dot { |
| | | width: 8px; |
| | | height: 8px; |
| | | border-radius: 50%; |
| | | margin-right: 6px; |
| | | } |
| | | |
| | | .process-node-dot.completed { |
| | | background-color: #52c41a; |
| | | } |
| | | .process-node-dot.in-progress { |
| | | background-color: #1890ff; |
| | | } |
| | | .process-node-dot.pending { |
| | | background-color: #d9d9d9; |
| | | } |
| | | |
| | | .process-node-info { |
| | | display: flex; |
| | | flex-direction: column; |
| | | justify-content: center; |
| | | margin-right: 12px; |
| | | font-size: 12px; |
| | | line-height: 1.2; |
| | | } |
| | | |
| | | .process-node-name { |
| | | color: #333; |
| | | max-width: 80px; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .process-node-percent { |
| | | color: #888; |
| | | font-size: 11px; |
| | | } |
| | | |
| | | .process-node-line { |
| | | width: 20px; |
| | | height: 1px; |
| | | background-color: #e8e8e8; |
| | | margin-right: 12px; |
| | | } |
| | | |
| | | </style> |
| | |
| | | <script lang="ts" setup> |
| | | import type { PendingTask } from '../../types'; |
| | | |
| | | |
| | | import { computed, ref } from 'vue'; |
| | | |
| | |
| | | |
| | | const emit = defineEmits<{ |
| | | select: [task: PendingTask]; |
| | | refresh: []; |
| | | }>(); |
| | | |
| | | import { pauseTask, resumeTask } from '#/api/mes/pro/task'; |
| | | import { Form, FormItem, Input, Modal, message, Button } from 'ant-design-vue'; |
| | | import { confirm } from '@vben/common-ui'; |
| | | import type {PendingTask} from "#/views/mes/workbench/types"; |
| | | |
| | | const searchKeyword = ref(''); |
| | | |
| | |
| | | if (rate >= 100) return '#52c41a'; |
| | | if (rate >= 60) return '#1890ff'; |
| | | return '#faad14'; |
| | | } |
| | | |
| | | const pauseModalVisible = ref(false); |
| | | const pauseFormRef = ref(); |
| | | const pauseFormState = ref({ id: 0, reason: '', remark: '' }); |
| | | const pauseLoading = ref(false); |
| | | |
| | | const pauseRules = { |
| | | reason: [{ required: true, message: '请输入停工原因', trigger: 'blur' }], |
| | | }; |
| | | |
| | | function handlePause(item: PendingTask, event: Event) { |
| | | event.stopPropagation(); |
| | | pauseFormState.value = { id: item.id!, reason: '', remark: '' }; |
| | | pauseModalVisible.value = true; |
| | | } |
| | | |
| | | async function submitPause() { |
| | | await pauseFormRef.value?.validate(); |
| | | pauseLoading.value = true; |
| | | try { |
| | | await pauseTask(pauseFormState.value); |
| | | message.success('停工成功'); |
| | | pauseModalVisible.value = false; |
| | | emit('refresh'); |
| | | } finally { |
| | | pauseLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function handleResume(item: PendingTask, event: Event) { |
| | | event.stopPropagation(); |
| | | try { |
| | | await confirm('确认要复工该任务吗?'); |
| | | } catch { |
| | | return; |
| | | } |
| | | try { |
| | | await resumeTask({ id: item.id! }); |
| | | message.success('复工成功'); |
| | | emit('refresh'); |
| | | } catch (error: any) { |
| | | // Error is handled by request interceptor |
| | | } |
| | | } |
| | | </script> |
| | | |
| | |
| | | > |
| | | {{ item.feedbackRate ?? 0 }}% |
| | | </span> |
| | | <div class="task-card__actions"> |
| | | <Button |
| | | v-if="item.status === 9" |
| | | type="primary" |
| | | size="small" |
| | | class="task-card__action-btn resume-btn" |
| | | @click="handleResume(item, $event)" |
| | | > |
| | | 复工 |
| | | </Button> |
| | | <Button |
| | | v-else |
| | | type="primary" |
| | | danger |
| | | size="small" |
| | | class="task-card__action-btn pause-btn" |
| | | @click="handlePause(item, $event)" |
| | | > |
| | | 停工 |
| | | </Button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="task-card__progress-row"> |
| | |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <Modal |
| | | v-model:open="pauseModalVisible" |
| | | title="生产任务停工" |
| | | @ok="submitPause" |
| | | :confirm-loading="pauseLoading" |
| | | destroy-on-close |
| | | > |
| | | <Form |
| | | ref="pauseFormRef" |
| | | :model="pauseFormState" |
| | | :rules="pauseRules" |
| | | :label-col="{ span: 4 }" |
| | | :wrapper-col="{ span: 20 }" |
| | | style="margin-top: 20px" |
| | | > |
| | | <FormItem label="停工原因" name="reason"> |
| | | <Input.TextArea |
| | | v-model:value="pauseFormState.reason" |
| | | placeholder="请输入停工原因(如:设备故障、缺料等)" |
| | | :rows="3" |
| | | :maxlength="100" |
| | | show-count |
| | | /> |
| | | </FormItem> |
| | | <FormItem label="备注" name="remark"> |
| | | <Input.TextArea |
| | | v-model:value="pauseFormState.remark" |
| | | placeholder="请输入备注说明" |
| | | :rows="2" |
| | | :maxlength="200" |
| | | show-count |
| | | /> |
| | | </FormItem> |
| | | </Form> |
| | | </Modal> |
| | | </section> |
| | | </template> |
| | | |
| | |
| | | font-variant-numeric: tabular-nums; |
| | | } |
| | | |
| | | .task-card__actions { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-left: auto; |
| | | } |
| | | |
| | | .task-card__action-btn { |
| | | font-size: 13px; |
| | | border-radius: 4px; |
| | | } |
| | | |
| | | .pause-btn { |
| | | box-shadow: 0 2px 4px rgb(255 77 79 / 20%); |
| | | } |
| | | |
| | | .resume-btn { |
| | | background-color: #52c41a; |
| | | border-color: #52c41a; |
| | | box-shadow: 0 2px 4px rgb(82 196 26 / 20%); |
| | | } |
| | | .resume-btn:hover, |
| | | .resume-btn:focus { |
| | | background-color: #73d13d; |
| | | border-color: #73d13d; |
| | | } |
| | | |
| | | .task-card__progress-row { |
| | | display: flex; |
| | | align-items: baseline; |
| | |
| | | <script lang="ts" setup> |
| | | import type { PendingTask } from '../../../types'; |
| | | |
| | | import { Descriptions, Progress, Tag } from 'ant-design-vue'; |
| | | import { Descriptions, Progress, Tag, Timeline, TimelineItem } from 'ant-design-vue'; |
| | | |
| | | defineOptions({ name: 'WorkbenchTaskDetailTab' }); |
| | | |
| | |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="备注" :span="2">{{ task.remark ?? '—' }}</Descriptions.Item> |
| | | </Descriptions> |
| | | |
| | | <!-- 停工记录 --> |
| | | <div v-if="task.pauseRecords && task.pauseRecords.length > 0" class="detail-pause-records"> |
| | | <div class="detail-pause-records__title">停工历史记录</div> |
| | | <Timeline class="pause-timeline"> |
| | | <TimelineItem |
| | | v-for="record in task.pauseRecords" |
| | | :key="record.id" |
| | | :color="record.resumeTime ? 'green' : 'red'" |
| | | > |
| | | <div class="pause-timeline-item"> |
| | | <div class="pause-timeline-time"> |
| | | <span class="time-label">{{ record.pauseTime }}</span> |
| | | <span class="time-separator">至</span> |
| | | <span class="time-label" :class="{ 'is-ongoing': !record.resumeTime }"> |
| | | {{ record.resumeTime || '正在停工' }} |
| | | </span> |
| | | </div> |
| | | <div class="pause-timeline-content"> |
| | | <div class="pause-info-row"> |
| | | <span class="info-label">操作人员:</span> |
| | | <span class="info-value"> |
| | | {{ record.pauseUserName }} (停工) |
| | | <template v-if="record.resumeTime"> |
| | | <span style="margin: 0 4px; color: #d9d9d9">|</span> |
| | | {{ record.resumeUserName }} (复工) |
| | | </template> |
| | | </span> |
| | | </div> |
| | | <div class="pause-info-row"> |
| | | <span class="info-label">停工原因:</span> |
| | | <span class="info-value">{{ record.reason }}</span> |
| | | </div> |
| | | <div class="pause-info-row" v-if="record.remark"> |
| | | <span class="info-label">备注:</span> |
| | | <span class="info-value">{{ record.remark }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </TimelineItem> |
| | | </Timeline> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | </template> |
| | |
| | | font-size: 14px; |
| | | } |
| | | } |
| | | |
| | | .detail-pause-records { |
| | | margin-top: 24px; |
| | | } |
| | | |
| | | .detail-pause-records__title { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #1a1a1a; |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | .pause-timeline { |
| | | padding-top: 6px; |
| | | padding-left: 4px; |
| | | } |
| | | |
| | | .pause-timeline-item { |
| | | background: #fafafa; |
| | | border: 1px solid #f0f0f0; |
| | | border-radius: 8px; |
| | | padding: 14px 18px; |
| | | margin-top: -6px; |
| | | box-shadow: 0 1px 2px rgb(0 0 0 / 2%); |
| | | transition: all 0.3s ease; |
| | | |
| | | &:hover { |
| | | box-shadow: 0 4px 12px rgb(0 0 0 / 5%); |
| | | border-color: #e6d8d8; |
| | | } |
| | | } |
| | | |
| | | .pause-timeline-time { |
| | | font-size: 13px; |
| | | color: #595959; |
| | | font-weight: 500; |
| | | margin-bottom: 10px; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .time-label { |
| | | background: #e6f7ff; |
| | | color: #1890ff; |
| | | padding: 2px 8px; |
| | | border-radius: 4px; |
| | | font-variant-numeric: tabular-nums; |
| | | } |
| | | |
| | | .time-label.is-ongoing { |
| | | background: #fff1f0; |
| | | color: #f5222d; |
| | | } |
| | | |
| | | .time-separator { |
| | | color: #bfbfbf; |
| | | font-size: 12px; |
| | | } |
| | | |
| | | .pause-timeline-content { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 6px; |
| | | } |
| | | |
| | | .pause-info-row { |
| | | font-size: 13px; |
| | | display: flex; |
| | | line-height: 1.5; |
| | | } |
| | | |
| | | .pause-info-row .info-label { |
| | | color: #8c8c8c; |
| | | width: 70px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .pause-info-row .info-value { |
| | | color: #262626; |
| | | flex: 1; |
| | | } |
| | | </style> |
| | |
| | | :selected-id="selectedTask?.id" |
| | | :loading="taskLoading" |
| | | @select="handleSelectTask" |
| | | @refresh="loadTaskList" |
| | | /> |
| | | </aside> |
| | | |