zhangwencui
7 天以前 168c5f65c936eb707ea232057e808cf940a0c255
生产工作台功能开发
已添加13个文件
已修改4个文件
4042 ■■■■■ 文件已修改
src/api/mes/pro/feedback/index.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/task/index.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorderparamrecord/index.ts 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/basic.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/core.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/TaskListPanel.vue 353 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/TemplateSelectDialog.vue 262 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/WorkstationPanel.vue 265 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/WorkstationSelectDialog.vue 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/FeedbackHistoryTab.vue 292 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/FeedbackTab.vue 331 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/ProductIssueTab.vue 389 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/RecordParamTab.vue 699 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/ReturnIssueTab.vue 409 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/TaskDetailTab.vue 190 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/index.vue 430 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/types.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/feedback/index.ts
@@ -111,3 +111,10 @@
export function approveFeedback(id: number) {
  return requestClient.put<boolean>(`/mes/pro/feedback/approve?id=${id}`);
}
/** æŒ‰ä»»åŠ¡æŸ¥è¯¢æŠ¥å·¥åŽ†å² */
export function getFeedbackListByTask(taskId: number) {
  return requestClient.get<MesProFeedbackApi.Feedback[]>(
    `/mes/pro/feedback/list-by-task?taskId=${taskId}`,
  );
}
src/api/mes/pro/task/index.ts
@@ -54,6 +54,26 @@
    statuses?: number[];
    status?: number;
  }
  /** å¾…报工台账任务(pending-feedback-page å“åº”) */
  export interface PendingFeedbackTask extends Task {
    pendingQuantity?: number; // å¾…报工数量(= æŽ’产 - å·²ç”Ÿäº§ï¼‰
    inTransitQuantity?: number; // åœ¨é€”报工数量(草稿/审批中/待检验合计)
    reportableQuantity?: number; // å¯æŠ¥å·¥æ•°é‡ï¼ˆ= æŽ’产 - å·²ç”Ÿäº§ - åœ¨é€”)
    feedbackRate?: number; // æŠ¥å·¥å®Œæˆçއ(%)
  }
  /** å¾…报工台账分页查询参数 */
  export interface PendingFeedbackPageParams extends PageParam {
    processId?: number;
    routeId?: number;
    workOrderId?: number;
    workstationId?: number;
    code?: string;
    name?: string;
    checkFlag?: boolean;
    createTime?: string[];
  }
}
/** æŸ¥è¯¢ç”Ÿäº§ä»»åŠ¡åˆ†é¡µ */
@@ -93,3 +113,13 @@
export function getGanttTaskList(params: any) {
  return requestClient.get<any[]>('/mes/pro/task/gantt-list', { params });
}
/** æŸ¥è¯¢å¾…报工台账(分页) */
export function getPendingFeedbackPage(
  params: MesProTaskApi.PendingFeedbackPageParams,
) {
  return requestClient.get<PageResult<MesProTaskApi.PendingFeedbackTask>>(
    '/mes/pro/task/pending-feedback-page',
    { params },
  );
}
src/api/mes/pro/workorderparamrecord/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MesProWorkOrderParamRecordApi {
  /** å‚数记录明细 */
  export interface ParamRecordDetail {
    id?: number;
    templateDetailId?: number;
    paramCode?: string;
    paramName?: string;
    paramType?: number;
    unitMeasureId?: number;
    unitMeasureName?: string;
    paramValue?: string;
    remark?: string;
  }
  /** å·¥å•参数记录 */
  export interface WorkOrderParamRecord {
    id?: number;
    workOrderId?: number;
    templateId?: number;
    templateCode?: string;
    templateName?: string;
    recordNo?: string;
    remark?: string;
    createTime?: string;
    details?: ParamRecordDetail[];
  }
  /** åˆ›å»º/更新请求参数 */
  export interface SaveParamRecord {
    id?: number;
    templateId?: number;
    recordNo?: string;
    remark?: string;
    details: {
      templateDetailId: number;
      paramValue?: string;
    }[];
  }
}
/** æŒ‰å·¥å•分页查询参数记录 */
export function getWorkOrderParamRecordPage(
  params: PageParam & { workOrderId: number; recordNo?: string; templateName?: string; createTime?: string[] },
) {
  return requestClient.get<PageResult<MesProWorkOrderParamRecordApi.WorkOrderParamRecord>>(
    '/mes/pro/work-order-param-record/page-by-work-order',
    { params },
  );
}
/** èŽ·å–å‚æ•°è®°å½•è¯¦æƒ… */
export function getWorkOrderParamRecord(id: number) {
  return requestClient.get<MesProWorkOrderParamRecordApi.WorkOrderParamRecord>(
    `/mes/pro/work-order-param-record/get?id=${id}`,
  );
}
/** åˆ›å»ºå‚数记录 */
export function createWorkOrderParamRecord(
  data: MesProWorkOrderParamRecordApi.SaveParamRecord,
  workOrderId: number,
) {
  return requestClient.post<number>(
    `/mes/pro/work-order-param-record/create?workOrderId=${workOrderId}`,
    data,
  );
}
/** æ›´æ–°å‚数记录 */
export function updateWorkOrderParamRecord(
  data: MesProWorkOrderParamRecordApi.SaveParamRecord,
) {
  return requestClient.put<boolean>(
    '/mes/pro/work-order-param-record/update',
    data,
  );
}
/** åˆ é™¤å‚数记录 */
export function deleteWorkOrderParamRecord(id: number) {
  return requestClient.delete<boolean>(
    `/mes/pro/work-order-param-record/delete?id=${id}`,
  );
}
src/layouts/basic.vue
@@ -303,6 +303,16 @@
        />
      </div>
    </template> -->
    <template #header-right-170>
      <button
        type="button"
        class="flex cursor-pointer items-center gap-1.5 rounded-md border-none bg-primary/15 px-3 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/25"
        @click="router.push({ name: 'MesWorkbench' })"
      >
        <IconifyIcon icon="lucide:factory" class="size-4" />
        <span>工作台</span>
      </button>
    </template>
    <template #extra>
      <AuthenticationLoginExpiredModal
        v-model:open="accessStore.loginExpired"
src/router/routes/core.ts
@@ -110,6 +110,21 @@
    ],
  },
  /**
   * ç”Ÿäº§å·¥ä½œå° - å…¨å±ç‹¬ç«‹é¡µé¢ï¼Œä¸ä½¿ç”¨ BasicLayout
   */
  {
    component: () => import('#/views/mes/workbench/index.vue'),
    meta: {
      hideInBreadcrumb: true,
      hideInMenu: true,
      hideInTab: true,
      ignoreAccess: true,
      title: '生产工作台',
    },
    name: 'MesWorkbench',
    path: '/mes/workbench',
  },
  /**
   * ç”¨äºŽ bpm ç§»åŠ¨ç«¯æµç¨‹è¡¨å• web-view çš„嵌入
   */
  {
src/views/mes/workbench/components/TaskListPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,353 @@
<script lang="ts" setup>
import type { PendingTask } from '../../types';
import { computed, ref } from 'vue';
import { Tag } from 'ant-design-vue';
defineOptions({ name: 'WorkbenchTaskListPanel' });
const props = defineProps<{
  list: PendingTask[];
  selectedId?: number;
  loading?: boolean;
}>();
const emit = defineEmits<{
  select: [task: PendingTask];
}>();
const searchKeyword = ref('');
const filteredList = computed(() => {
  const kw = searchKeyword.value.trim().toLowerCase();
  if (!kw) return props.list;
  return props.list.filter((row) => {
    const fields = [
      row.code,
      row.workOrderCode,
      row.workOrderName,
      row.itemCode,
      row.itemName,
      row.processName,
    ];
    return fields.some((f) => String(f ?? '').toLowerCase().includes(kw));
  });
});
function getProgressColor(rate?: number): string {
  if (rate === undefined || rate === null) return '#1890ff';
  if (rate >= 100) return '#52c41a';
  if (rate >= 60) return '#1890ff';
  return '#faad14';
}
</script>
<template>
  <section class="task-list-panel">
    <div class="task-list-panel__head">
      <span class="task-list-panel__title">工单列表</span>
      <Tag color="blue">{{ list.length }}</Tag>
    </div>
    <div class="task-list-panel__search">
      <input
        v-model="searchKeyword"
        class="task-list-panel__input"
        placeholder="订单/产品/工序"
      />
    </div>
    <div v-if="loading" class="task-list-panel__empty">加载中...</div>
    <div v-else-if="!list.length" class="task-list-panel__empty">
      æš‚无待报工任务
    </div>
    <div v-else-if="!filteredList.length" class="task-list-panel__empty">
      æ— åŒ¹é…å·¥å•
    </div>
    <div v-else class="task-list-panel__scroll">
      <div
        v-for="item in filteredList"
        :key="item.id"
        class="task-card"
        :class="{ 'task-card--active': item.id === selectedId }"
        @click="emit('select', item)"
      >
        <div class="task-card__accent" />
        <div class="task-card__inner">
          <div class="task-card__header">
            <span class="task-card__code">{{ item.code }}</span>
            <Tag
              v-if="item.checkFlag"
              color="orange"
              class="task-card__check-tag"
            >
              è´¨æ£€
            </Tag>
            <span
              class="task-card__rate"
              :style="{ color: getProgressColor(item.feedbackRate) }"
            >
              {{ item.feedbackRate ?? 0 }}%
            </span>
          </div>
          <div class="task-card__progress-row">
            <span class="task-card__progress-label">报工进度</span>
            <span class="task-card__progress-qty">
              {{ item.producedQuantity ?? 0 }} / {{ item.quantity ?? 0 }}
            </span>
          </div>
          <div class="task-card__progress-bar">
            <div
              class="task-card__progress-fill"
              :style="{
                width: `${Math.min(item.feedbackRate ?? 0, 100)}%`,
                backgroundColor: getProgressColor(item.feedbackRate),
              }"
            />
          </div>
          <div class="task-card__info">
            <div class="task-card__info-row">
              <span class="task-card__info-label">订单</span>
              <span class="task-card__info-value">
                {{ item.workOrderCode }} {{ item.workOrderName }}
              </span>
            </div>
            <div class="task-card__info-row">
              <span class="task-card__info-label">工序</span>
              <span class="task-card__info-value">{{ item.processName }}</span>
            </div>
            <div class="task-card__info-row">
              <span class="task-card__info-label">产品</span>
              <span class="task-card__info-value" :title="item.itemName">
                {{ item.itemCode }} {{ item.itemName }}
              </span>
            </div>
            <div class="task-card__info-row">
              <span class="task-card__info-label">可报工</span>
              <span class="task-card__info-value task-card__info-value--highlight">
                {{ item.reportableQuantity ?? 0 }} {{ item.unitMeasureName }}
              </span>
            </div>
          </div>
        </div>
      </div>
    </div>
  </section>
</template>
<style lang="scss" scoped>
.task-list-panel {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: #fff;
  border-radius: 8px;
  border: 1px solid #f0f0f0;
  box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
  overflow: hidden;
}
.task-list-panel__head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px;
  flex-shrink: 0;
  border-bottom: 1px solid #f0f0f0;
}
.task-list-panel__title {
  font-size: 16px;
  font-weight: 600;
  color: #1a1a1a;
}
.task-list-panel__search {
  flex-shrink: 0;
  padding: 10px 14px;
  border-bottom: 1px solid #f0f0f0;
}
.task-list-panel__input {
  width: 100%;
  height: 36px;
  padding: 0 12px;
  border: 1px solid #d9d9d9;
  border-radius: 6px;
  font-size: 14px;
  outline: none;
  transition: border-color 0.2s;
  &:focus {
    border-color: var(--ant-primary-color, #1890ff);
    box-shadow: 0 0 0 2px rgb(24 144 255 / 10%);
  }
  &::placeholder {
    color: #bfbfbf;
  }
}
.task-list-panel__empty {
  padding: 32px 0;
  text-align: center;
  color: #8c8c8c;
  font-size: 14px;
}
.task-list-panel__scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 10px;
}
.task-card {
  position: relative;
  margin-bottom: 10px;
  border-radius: 8px;
  cursor: pointer;
  border: 1px solid #f0f0f0;
  background: #fff;
  transition: all 0.15s ease;
  &:last-child {
    margin-bottom: 0;
  }
  &:hover {
    border-color: var(--ant-primary-color, #1890ff);
  }
  &--active {
    border-color: var(--ant-primary-color, #1890ff);
    box-shadow: 0 1px 8px rgb(24 144 255 / 15%);
    .task-card__accent {
      opacity: 1;
    }
    .task-card__inner {
      background: #f0f7ff;
    }
  }
}
.task-card__accent {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 4px;
  background: var(--ant-primary-color, #1890ff);
  opacity: 0;
  border-radius: 8px 0 0 8px;
}
.task-card__inner {
  padding: 12px 14px 12px 16px;
}
.task-card__header {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}
.task-card__code {
  flex: 1;
  min-width: 0;
  font-weight: 600;
  font-size: 14px;
  color: var(--ant-primary-color, #1890ff);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.task-card__check-tag {
  flex-shrink: 0;
}
.task-card__rate {
  flex-shrink: 0;
  font-size: 16px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.task-card__progress-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 6px;
}
.task-card__progress-label {
  font-size: 12px;
  font-weight: 600;
  color: #8c8c8c;
}
.task-card__progress-qty {
  font-size: 13px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: #1a1a1a;
}
.task-card__progress-bar {
  height: 8px;
  background: #f0f0f0;
  border-radius: 4px;
  overflow: hidden;
  margin-bottom: 10px;
}
.task-card__progress-fill {
  height: 100%;
  border-radius: 4px;
  transition: width 0.3s ease;
}
.task-card__info {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.task-card__info-row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  line-height: 1.4;
}
.task-card__info-label {
  flex-shrink: 0;
  width: 48px;
  color: #8c8c8c;
}
.task-card__info-value {
  flex: 1;
  min-width: 0;
  color: #1a1a1a;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  &--highlight {
    color: var(--ant-primary-color, #1890ff);
    font-weight: 600;
  }
}
</style>
src/views/mes/workbench/components/TemplateSelectDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,262 @@
<script lang="ts" setup>
import type { MesPdTemplateApi } from '#/api/mes/pro/processdesign/template';
import { computed, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { getDictOptions } from '@vben/hooks';
import { Input, Modal, Spin, Tag } from 'ant-design-vue';
import { getTemplatePage } from '#/api/mes/pro/processdesign/template';
defineOptions({ name: 'WorkbenchTemplateSelectDialog' });
const emit = defineEmits<{
  select: [template: MesPdTemplateApi.Template];
}>();
const visible = ref(false);
const loading = ref(false);
const templates = ref<MesPdTemplateApi.Template[]>([]);
const searchKeyword = ref('');
/** æ‰“开弹窗 */
async function open() {
  visible.value = true;
  searchKeyword.value = '';
  await loadTemplates();
}
/** åŠ è½½æ¨¡æ¿åˆ—è¡¨ */
async function loadTemplates() {
  loading.value = true;
  try {
    const result = await getTemplatePage({ pageNo: 1, pageSize: 200 });
    templates.value = result.list ?? [];
  } finally {
    loading.value = false;
  }
}
/** è¿‡æ»¤åŽçš„æ¨¡æ¿ */
const filtered = computed(() => {
  const kw = searchKeyword.value.trim().toLowerCase();
  if (!kw) return templates.value;
  return templates.value.filter((t) => {
    const fields = [t.templateCode, t.templateName, t.remark];
    return fields.some((f) => String(f ?? '').toLowerCase().includes(kw));
  });
});
/** æ¨¡æ¿ç±»åž‹å­—å…¸ */
const templateTypeOptions = getDictOptions(DICT_TYPE.MES_PD_TEMPLATE_TYPE, 'number') ?? [];
function getTemplateTypeLabel(type?: number) {
  const opt = templateTypeOptions.find((o: any) => o.value === type);
  return opt?.label ?? '';
}
function getTemplateTypeColor(type?: number) {
  const colors: Record<number, string> = { 1: 'blue', 2: 'green', 3: 'orange' };
  return type ? (colors[type] ?? 'default') : 'default';
}
/** é€‰æ‹©æ¨¡æ¿ */
function handleSelect(t: MesPdTemplateApi.Template) {
  emit('select', t);
  visible.value = false;
}
defineExpose({ open });
</script>
<template>
  <Modal
    v-model:open="visible"
    title="选择工艺参数模板"
    :footer="null"
    width="900px"
    :body-style="{ padding: '16px 20px', maxHeight: '70vh', overflow: 'auto' }"
    centered
    destroy-on-close
  >
    <!-- æœç´¢æ¡† -->
    <div class="tpl-select__search">
      <Input
        v-model:value="searchKeyword"
        placeholder="搜索模板编码/名称"
        allow-clear
      >
        <template #prefix>
          <IconifyIcon icon="lucide:search" class="text-gray-400" />
        </template>
      </Input>
    </div>
    <Spin :spinning="loading">
      <!-- ç©ºçŠ¶æ€ -->
      <div v-if="!filtered.length && !loading" class="tpl-select__empty">
        <IconifyIcon icon="lucide:inbox" class="tpl-select__empty-icon" />
        <p>暂无工艺参数模板</p>
      </div>
      <!-- å¡ç‰‡ç½‘æ ¼ -->
      <div v-else class="tpl-select__grid">
        <button
          v-for="t in filtered"
          :key="t.id"
          type="button"
          class="tpl-card"
          @click="handleSelect(t)"
        >
          <div class="tpl-card__head">
            <span class="tpl-card__code">{{ t.templateCode }}</span>
            <Tag v-if="t.templateType" :color="getTemplateTypeColor(t.templateType)">
              {{ getTemplateTypeLabel(t.templateType) }}
            </Tag>
          </div>
          <div class="tpl-card__name">{{ t.templateName }}</div>
          <div v-if="t.details?.length" class="tpl-card__params">
            <IconifyIcon icon="lucide:settings-2" class="tpl-card__params-icon" />
            <span>{{ t.details.length }} ä¸ªå‚æ•°</span>
          </div>
          <div v-if="t.remark" class="tpl-card__remark">{{ t.remark }}</div>
          <div class="tpl-card__action">
            <IconifyIcon icon="lucide:pointer" />
            ç‚¹å‡»é€‰æ‹©
          </div>
        </button>
      </div>
    </Spin>
  </Modal>
</template>
<style lang="scss" scoped>
.tpl-select__search {
  margin-bottom: 16px;
}
.tpl-select__empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 48px 0;
  color: #8c8c8c;
  p {
    margin: 0;
    font-size: 15px;
  }
}
.tpl-select__empty-icon {
  font-size: 40px;
  opacity: 0.5;
}
.tpl-select__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 16px;
}
.tpl-card {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 16px;
  border: 2px solid #e8e8e8;
  border-radius: 12px;
  text-align: left;
  cursor: pointer;
  background: linear-gradient(
    145deg,
    #f0fdf4 0%,
    #dcfce7 52%,
    #bbf7d0 100%
  );
  transition:
    transform 0.18s ease,
    box-shadow 0.18s ease,
    border-color 0.18s ease;
  &:hover {
    transform: translateY(-2px);
    border-color: #22c55e;
    box-shadow: 0 8px 24px rgb(34 197 94 / 20%);
  }
  &:active {
    transform: translateY(0);
  }
}
.tpl-card__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.tpl-card__code {
  font-size: 18px;
  font-weight: 800;
  letter-spacing: 0.03em;
  color: #166534;
  line-height: 1.1;
}
.tpl-card__name {
  font-size: 15px;
  font-weight: 600;
  color: #14532d;
  line-height: 1.3;
}
.tpl-card__params {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: #475569;
}
.tpl-card__params-icon {
  font-size: 14px;
  color: #64748b;
}
.tpl-card__remark {
  font-size: 12px;
  color: #6b7280;
  line-height: 1.4;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}
.tpl-card__action {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 8px;
  border-radius: 8px;
  background: rgb(34 197 94 / 12%);
  border: 1px solid rgb(34 197 94 / 20%);
  font-size: 14px;
  font-weight: 600;
  color: #16a34a;
  transition: all 0.2s;
  margin-top: auto;
  .tpl-card:hover & {
    background: rgb(34 197 94 / 20%);
  }
}
</style>
src/views/mes/workbench/components/WorkstationPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,265 @@
<script lang="ts" setup>
import type { MesMdWorkstationApi } from '#/api/mes/md/workstation';
import { IconifyIcon } from '@vben/icons';
defineOptions({ name: 'WorkbenchWorkstationPanel' });
defineProps<{
  workstation?: MesMdWorkstationApi.Workstation;
}>();
const emit = defineEmits<{
  change: [];
}>();
</script>
<template>
  <section
    class="ws-panel"
    :class="{ 'ws-panel--empty': !workstation }"
  >
    <div class="ws-panel__head">
      <div class="ws-panel__title-row">
        <IconifyIcon icon="lucide:map-pin" class="ws-panel__icon" />
        <span class="ws-panel__title">工作站</span>
      </div>
      <button
        v-if="workstation"
        type="button"
        class="ws-panel__switch"
        @click="emit('change')"
      >
        åˆ‡æ¢
      </button>
    </div>
    <!-- å·²é€‰å·¥ä½œç«™ -->
    <div v-if="workstation" class="ws-panel__device">
      <span class="ws-panel__code">{{ workstation.code }}</span>
      <span class="ws-panel__name">{{ workstation.name }}</span>
      <span v-if="workstation.processName" class="ws-panel__process">
        {{ workstation.processName }}
      </span>
    </div>
    <!-- æœªé€‰å·¥ä½œç«™ -->
    <div v-else class="ws-panel__placeholder">
      <IconifyIcon icon="lucide:monitor" class="ws-panel__placeholder-icon" />
      <p>尚未选择工作站</p>
      <button type="button" class="ws-panel__select-btn" @click="emit('change')">
        é€‰æ‹©å·¥ä½œç«™
      </button>
    </div>
    <div v-if="workstation" class="ws-panel__meta">
      <div class="ws-panel__meta-item">
        <span class="ws-panel__meta-label">车间</span>
        <span class="ws-panel__meta-value">{{ workstation.workshopName ?? '—' }}</span>
      </div>
      <div class="ws-panel__meta-item">
        <span class="ws-panel__meta-label">地点</span>
        <span class="ws-panel__meta-value">{{ workstation.address ?? '—' }}</span>
      </div>
    </div>
  </section>
</template>
<style lang="scss" scoped>
$radius: 10px;
$shadow: 0 2px 12px rgb(0 0 0 / 8%);
.ws-panel {
  flex-shrink: 0;
  padding: 14px 16px;
  border-radius: $radius;
  color: #fff;
  background: linear-gradient(
    128deg,
    #3b82f6 0%,
    #2563eb 38%,
    #1d4ed8 100%
  );
  box-shadow: $shadow;
  border: 1px solid rgb(255 255 255 / 14%);
  &--empty {
    .ws-panel__meta {
      opacity: 0.72;
    }
  }
}
.ws-panel__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 12px;
}
.ws-panel__title-row {
  display: flex;
  align-items: center;
  gap: 8px;
}
.ws-panel__icon {
  font-size: 20px;
  opacity: 0.95;
}
.ws-panel__title {
  font-size: 17px;
  font-weight: 600;
  letter-spacing: 0.02em;
}
.ws-panel__switch {
  flex-shrink: 0;
  padding: 6px 14px;
  border: 1px solid rgb(255 255 255 / 36%);
  border-radius: 8px;
  background: rgb(255 255 255 / 14%);
  color: #fff;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
  &:hover {
    background: rgb(255 255 255 / 24%);
  }
}
.ws-panel__device {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 12px;
  padding: 12px 16px;
  border-radius: 12px;
  background: linear-gradient(
    135deg,
    rgb(255 255 255 / 24%) 0%,
    rgb(255 255 255 / 10%) 100%
  );
  border: 2px solid rgb(255 255 255 / 28%);
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 22%),
    0 8px 20px rgb(0 0 0 / 12%);
}
.ws-panel__code {
  flex-shrink: 0;
  font-size: 28px;
  font-weight: 800;
  letter-spacing: 0.04em;
  line-height: 1;
  color: #fff176;
  text-shadow:
    0 1px 2px rgb(0 0 0 / 35%),
    0 0 12px rgb(255 241 118 / 45%);
}
.ws-panel__name {
  flex: 1;
  min-width: 0;
  font-size: 17px;
  font-weight: 700;
  line-height: 1.2;
  color: #fff9c4;
  text-shadow: 0 1px 3px rgb(0 0 0 / 22%);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ws-panel__process {
  flex-shrink: 0;
  padding: 4px 12px;
  border-radius: 999px;
  font-size: 14px;
  font-weight: 700;
  background: rgb(255 193 7 / 92%);
  color: #5c3d00;
  box-shadow: 0 2px 8px rgb(0 0 0 / 12%);
}
.ws-panel__placeholder {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  margin-bottom: 12px;
  padding: 18px 12px;
  border-radius: 12px;
  background: rgb(0 0 0 / 12%);
  border: 1px dashed rgb(255 255 255 / 28%);
  text-align: center;
  p {
    margin: 0;
    font-size: 15px;
    opacity: 0.92;
  }
}
.ws-panel__placeholder-icon {
  font-size: 32px;
  opacity: 0.85;
}
.ws-panel__select-btn {
  padding: 10px 24px;
  border: 1px solid rgb(255 255 255 / 40%);
  border-radius: 8px;
  background: rgb(255 255 255 / 18%);
  color: #fff;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  transition: all 0.2s;
  min-height: 44px;
  &:hover {
    background: rgb(255 255 255 / 28%);
    border-color: rgb(255 255 255 / 55%);
  }
}
.ws-panel__meta {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}
.ws-panel__meta-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 8px 12px;
  border-radius: 10px;
  background: rgb(255 255 255 / 16%);
  border: 1px solid rgb(255 255 255 / 22%);
}
.ws-panel__meta-label {
  flex-shrink: 0;
  font-size: 13px;
  font-weight: 600;
  opacity: 0.88;
}
.ws-panel__meta-value {
  min-width: 0;
  font-size: 14px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: #fff9c4;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
</style>
src/views/mes/workbench/components/WorkstationSelectDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,258 @@
<script lang="ts" setup>
import type { MesMdWorkstationApi } from '#/api/mes/md/workstation';
import { computed, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Input, Modal, Spin } from 'ant-design-vue';
import { getWorkstationPage } from '#/api/mes/md/workstation';
defineOptions({ name: 'WorkbenchWorkstationSelectDialog' });
const emit = defineEmits<{
  select: [workstation: MesMdWorkstationApi.Workstation];
}>();
const visible = ref(false);
const loading = ref(false);
const workstations = ref<MesMdWorkstationApi.Workstation[]>([]);
const searchKeyword = ref('');
/** æ‰“开弹窗 */
async function open() {
  visible.value = true;
  searchKeyword.value = '';
  await loadWorkstations();
}
/** åŠ è½½å·¥ä½œç«™åˆ—è¡¨ */
async function loadWorkstations() {
  loading.value = true;
  try {
    const result = await getWorkstationPage({ pageNo: 1, pageSize: 200 });
    workstations.value = result.list ?? [];
  } finally {
    loading.value = false;
  }
}
/** è¿‡æ»¤åŽçš„工作站 */
const filtered = computed(() => {
  const kw = searchKeyword.value.trim().toLowerCase();
  if (!kw) return workstations.value;
  return workstations.value.filter((ws) => {
    const fields = [ws.code, ws.name, ws.processName, ws.workshopName, ws.address];
    return fields.some((f) => String(f ?? '').toLowerCase().includes(kw));
  });
});
/** é€‰æ‹©å·¥ä½œç«™ */
function handleSelect(ws: MesMdWorkstationApi.Workstation) {
  emit('select', ws);
  visible.value = false;
}
defineExpose({ open });
</script>
<template>
  <Modal
    v-model:open="visible"
    title="选择工作站"
    :footer="null"
    width="900px"
    :body-style="{ padding: '16px 20px', maxHeight: '70vh', overflow: 'auto' }"
    centered
    destroy-on-close
  >
    <!-- æœç´¢æ¡† -->
    <div class="ws-select__search">
      <Input
        v-model:value="searchKeyword"
        placeholder="搜索工作站编码/名称/工序/车间"
        allow-clear
      >
        <template #prefix>
          <IconifyIcon icon="lucide:search" class="text-gray-400" />
        </template>
      </Input>
    </div>
    <Spin :spinning="loading">
      <!-- ç©ºçŠ¶æ€ -->
      <div v-if="!filtered.length && !loading" class="ws-select__empty">
        <IconifyIcon icon="lucide:inbox" class="ws-select__empty-icon" />
        <p>暂无工作站数据</p>
      </div>
      <!-- å¡ç‰‡ç½‘æ ¼ -->
      <div v-else class="ws-select__grid">
        <button
          v-for="ws in filtered"
          :key="ws.id"
          type="button"
          class="ws-card"
          @click="handleSelect(ws)"
        >
          <div class="ws-card__head">
            <span class="ws-card__code">{{ ws.code }}</span>
            <span v-if="ws.processName" class="ws-card__process">
              {{ ws.processName }}
            </span>
          </div>
          <div class="ws-card__name">{{ ws.name }}</div>
          <div class="ws-card__info">
            <div v-if="ws.workshopName" class="ws-card__info-row">
              <IconifyIcon icon="lucide:building-2" class="ws-card__info-icon" />
              <span>{{ ws.workshopName }}</span>
            </div>
            <div v-if="ws.address" class="ws-card__info-row">
              <IconifyIcon icon="lucide:map-pin" class="ws-card__info-icon" />
              <span>{{ ws.address }}</span>
            </div>
          </div>
          <div class="ws-card__action">
            <IconifyIcon icon="lucide:pointer" />
            ç‚¹å‡»é€‰æ‹©
          </div>
        </button>
      </div>
    </Spin>
  </Modal>
</template>
<style lang="scss" scoped>
.ws-select__search {
  margin-bottom: 16px;
}
.ws-select__empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 48px 0;
  color: #8c8c8c;
  p {
    margin: 0;
    font-size: 15px;
  }
}
.ws-select__empty-icon {
  font-size: 40px;
  opacity: 0.5;
}
.ws-select__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 16px;
}
.ws-card {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 16px;
  border: 2px solid #e8e8e8;
  border-radius: 12px;
  text-align: left;
  cursor: pointer;
  background: linear-gradient(
    145deg,
    #eff6ff 0%,
    #dbeafe 52%,
    #bfdbfe 100%
  );
  transition:
    transform 0.18s ease,
    box-shadow 0.18s ease,
    border-color 0.18s ease;
  &:hover {
    transform: translateY(-2px);
    border-color: #3b82f6;
    box-shadow: 0 8px 24px rgb(59 130 246 / 20%);
  }
  &:active {
    transform: translateY(0);
  }
}
.ws-card__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.ws-card__code {
  font-size: 22px;
  font-weight: 800;
  letter-spacing: 0.03em;
  color: #1e40af;
  line-height: 1.1;
}
.ws-card__process {
  flex-shrink: 0;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 700;
  background: #3b82f6;
  color: #fff;
}
.ws-card__name {
  font-size: 15px;
  font-weight: 600;
  color: #1e3a5f;
  line-height: 1.3;
}
.ws-card__info {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-top: auto;
}
.ws-card__info-row {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: #475569;
}
.ws-card__info-icon {
  font-size: 14px;
  color: #64748b;
  flex-shrink: 0;
}
.ws-card__action {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 8px;
  border-radius: 8px;
  background: rgb(59 130 246 / 12%);
  border: 1px solid rgb(59 130 246 / 20%);
  font-size: 14px;
  font-weight: 600;
  color: #2563eb;
  transition: all 0.2s;
  .ws-card:hover & {
    background: rgb(59 130 246 / 20%);
  }
}
</style>
src/views/mes/workbench/components/tabs/FeedbackHistoryTab.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,292 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProFeedbackApi } from '#/api/mes/pro/feedback';
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
  DICT_TYPE,
  MesProFeedbackStatusEnum,
} from '@vben/constants';
import { useVbenModal } from '@vben/common-ui';
import { Button, message, Popconfirm, Space } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  approveFeedback,
  deleteFeedback,
  getFeedbackListByTask,
  rejectFeedback,
  submitFeedback,
} from '#/api/mes/pro/feedback';
import { $t } from '#/locales';
import FeedbackForm from '../../../pro/feedback/modules/form.vue';
defineOptions({ name: 'WorkbenchFeedbackHistoryTab' });
const props = defineProps<{
  taskId?: number;
  /** å¤–部触发刷新 */
  refreshKey?: number;
}>();
const emit = defineEmits<{ reported: [] }>();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: FeedbackForm,
  destroyOnClose: true,
});
/** æŸ¥çœ‹è¯¦æƒ… */
function handleDetail(row: MesProFeedbackApi.Feedback) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** ç¼–辑(仅草稿) */
function handleEdit(row: MesProFeedbackApi.Feedback) {
  formModalApi.setData({ formType: 'update', id: row.id }).open();
}
/** åˆ é™¤ï¼ˆä»…草稿) */
async function handleDelete(row: MesProFeedbackApi.Feedback) {
  await deleteFeedback(row.id!);
  message.success('删除成功');
  refreshGrid();
  emit('reported');
}
/** æäº¤ï¼ˆä»…草稿) */
async function handleSubmit(row: MesProFeedbackApi.Feedback) {
  await submitFeedback(row.id!);
  message.success('报工单已提交');
  refreshGrid();
  emit('reported');
}
/** å®¡æ‰¹é€šè¿‡ï¼ˆä»…审批中) */
async function handleApprove(row: MesProFeedbackApi.Feedback) {
  const finished = await approveFeedback(row.id!);
  message.success(
    finished ? '报工单已审批完成' : '报工成功,请等待质量检验完成!',
  );
  refreshGrid();
  emit('reported');
}
/** é©³å›žï¼ˆä»…审批中) */
async function handleReject(row: MesProFeedbackApi.Feedback) {
  await rejectFeedback(row.id!);
  message.success('报工单已驳回');
  refreshGrid();
  emit('reported');
}
/** çŠ¶æ€å¸¸é‡ä¾›æ¨¡æ¿ä½¿ç”¨ */
const STATUS_PREPARE = MesProFeedbackStatusEnum.PREPARE;
const STATUS_APPROVING = MesProFeedbackStatusEnum.APPROVING;
const gridHeight = ref(300);
const gridContainerRef = ref<HTMLElement>();
let resizeObserver: ResizeObserver | null = null;
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: [
      {
        field: 'code',
        title: '报订单号',
        width: 160,
        slots: { default: 'code' },
      },
      { field: 'workOrderCode', title: '工单编码', width: 140 },
      { field: 'processName', title: '工序', width: 100 },
      { field: 'itemName', title: '产品', minWidth: 120 },
      { field: 'feedbackQuantity', title: '报工数量', width: 100 },
      { field: 'qualifiedQuantity', title: '合格品', width: 90 },
      { field: 'unqualifiedQuantity', title: '不良品', width: 90 },
      { field: 'feedbackUserNickname', title: '报工人', width: 100 },
      { field: 'approveUserNickname', title: '审核人', width: 100 },
      {
        field: 'status',
        title: '状态',
        width: 100,
        cellRender: {
          name: 'CellDict',
          props: { type: DICT_TYPE.MES_PRO_FEEDBACK_STATUS },
        },
      },
      {
        field: 'feedbackTime',
        title: '报工时间',
        width: 170,
        formatter: 'formatDateTime',
      },
      {
        title: '操作',
        width: 260,
        fixed: 'right',
        slots: { default: 'actions' },
      },
    ],
    height: gridHeight.value,
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async () => {
          if (!props.taskId) {
            return [];
          }
          return await getFeedbackListByTask(props.taskId);
        },
      },
    },
    rowConfig: { isHover: true, keyField: 'id' },
    toolbarConfig: { refresh: true },
  } as VxeTableGridOptions<MesProFeedbackApi.Feedback>,
});
function updateGridHeight() {
  if (gridContainerRef.value) {
    const h = gridContainerRef.value.clientHeight;
    if (h > 100 && h !== gridHeight.value) {
      gridHeight.value = h;
      gridApi.setGridOptions({ height: h });
    }
  }
}
onMounted(() => {
  nextTick(() => {
    updateGridHeight();
    if (gridContainerRef.value) {
      resizeObserver = new ResizeObserver(() => updateGridHeight());
      resizeObserver.observe(gridContainerRef.value);
    }
  });
});
onBeforeUnmount(() => {
  resizeObserver?.disconnect();
  resizeObserver = null;
});
function refreshGrid() {
  gridApi.query();
}
// ç›‘听 taskId å˜åŒ–刷新
watch(
  () => props.taskId,
  () => {
    if (props.taskId) {
      gridApi.query();
    }
  },
);
// ç›‘听外部 refreshKey å˜åŒ–刷新
watch(
  () => props.refreshKey,
  () => {
    gridApi.query();
  },
);
</script>
<template>
  <div class="feedback-history-tab">
    <FormModal @success="refreshGrid" />
    <div v-if="!taskId" class="feedback-history-tab__empty">
      è¯·ä»Žå·¦ä¾§å·¥å•列表选择一条任务查看报工记录
    </div>
    <div v-else ref="gridContainerRef" class="feedback-history-tab__grid">
      <Grid table-title="报工记录">
        <template #code="{ row }">
          <Button type="link" @click="handleDetail(row)">
            {{ row.code }}
          </Button>
        </template>
        <template #actions="{ row }">
          <Space :size="4" wrap>
            <Button type="link" size="small" @click="handleDetail(row)">
              {{ $t('common.view') }}
            </Button>
            <!-- è‰ç¨¿ï¼šç¼–辑、删除、提交 -->
            <Button
              v-if="row.status === STATUS_PREPARE"
              type="link"
              size="small"
              @click="handleEdit(row)"
            >
              {{ $t('common.edit') }}
            </Button>
            <Popconfirm
              v-if="row.status === STATUS_PREPARE"
              title="确认提交该报工单?提交后将不能修改。"
              @confirm="handleSubmit(row)"
            >
              <Button type="link" size="small">
                {{ $t('common.submit') }}
              </Button>
            </Popconfirm>
            <Popconfirm
              v-if="row.status === STATUS_PREPARE"
              title="确认删除该报工单?"
              @confirm="handleDelete(row)"
            >
              <Button type="link" size="small" danger>
                {{ $t('common.delete') }}
              </Button>
            </Popconfirm>
            <!-- å®¡æ‰¹ä¸­ï¼šå®¡æ‰¹é€šè¿‡ã€é©³å›ž -->
            <Popconfirm
              v-if="row.status === STATUS_APPROVING"
              title="确认审批通过该报工单?"
              @confirm="handleApprove(row)"
            >
              <Button type="link" size="small">
                {{ $t('common.approve') }}
              </Button>
            </Popconfirm>
            <Popconfirm
              v-if="row.status === STATUS_APPROVING"
              title="确认驳回该报工单?"
              @confirm="handleReject(row)"
            >
              <Button type="link" size="small" danger>
                é©³å›ž
              </Button>
            </Popconfirm>
          </Space>
        </template>
      </Grid>
    </div>
  </div>
</template>
<style lang="scss" scoped>
.feedback-history-tab {
  padding: 16px 20px;
  height: 100%;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.feedback-history-tab__grid {
  flex: 1;
  min-height: 0;
  overflow: hidden;
}
.feedback-history-tab__empty {
  padding: 64px 0;
  text-align: center;
  color: #8c8c8c;
  font-size: 16px;
}
</style>
src/views/mes/workbench/components/tabs/FeedbackTab.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,331 @@
<script lang="ts" setup>
import type { PendingTask } from '../../../types';
import type { FormType } from '../../../pro/feedback/data';
import { computed, ref, watch } from 'vue';
import {
  MesAutoCodeRuleCode,
  MesProFeedbackStatusEnum,
} from '@vben/constants';
import { useUserStore } from '@vben/stores';
import { Button, message, Popconfirm } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import {
  approveFeedback,
  createFeedback,
  getFeedback,
  rejectFeedback,
  submitFeedback,
  updateFeedback,
} from '#/api/mes/pro/feedback';
import { getRouteProcessByRouteAndProcess } from '#/api/mes/pro/route/process';
import { $t } from '#/locales';
import { useFormSchema } from '../../../pro/feedback/data';
defineOptions({ name: 'WorkbenchFeedbackTab' });
const props = defineProps<{
  task?: PendingTask | null;
  /** å¤–部触发刷新(如报工成功后刷新列表) */
  refreshKey?: number;
}>();
const emit = defineEmits<{
  reported: [];
}>();
const userStore = useUserStore();
const formType = ref<FormType>('create');
const feedbackId = ref<number>();
const feedbackStatus = ref<number>();
const originalSnapshot = ref('');
const isEditable = computed(() =>
  ['create', 'submit', 'update'].includes(formType.value),
);
const canSubmit = computed(
  () =>
    isEditable.value &&
    feedbackStatus.value === MesProFeedbackStatusEnum.PREPARE,
);
const canApprove = computed(() => formType.value === 'approve');
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 110,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-3',
});
/** æ ¹æ® checkFlag å¯¹é½æ•°é‡ */
function alignQuantity(data: any) {
  if (data.checkFlag) {
    data.uncheckQuantity = data.feedbackQuantity;
    data.qualifiedQuantity = 0;
    data.unqualifiedQuantity = 0;
    data.laborScrapQuantity = 0;
    data.materialScrapQuantity = 0;
    data.otherScrapQuantity = 0;
  } else {
    data.feedbackQuantity =
      (data.qualifiedQuantity || 0) + (data.unqualifiedQuantity || 0);
    data.uncheckQuantity = 0;
  }
}
async function getAlignedData() {
  const data = (await formApi.getValues()) as any;
  alignQuantity(data);
  return data;
}
/** åŠ è½½ checkFlag */
async function resolveCheckFlag(routeId?: number, processId?: number) {
  if (!routeId || !processId) return true;
  try {
    const rp = await getRouteProcessByRouteAndProcess(routeId, processId);
    return rp?.checkFlag ?? false;
  } catch {
    return true;
  }
}
/** ä»»åŠ¡å˜æ›´æ—¶é‡ç½®è¡¨å• */
async function resetForTask(task: PendingTask) {
  formType.value = 'create';
  feedbackId.value = undefined;
  feedbackStatus.value = undefined;
  originalSnapshot.value = '';
  formApi.setState({ schema: useFormSchema(formType.value, formApi) });
  const code = await generateAutoCode(MesAutoCodeRuleCode.PRO_FEEDBACK_CODE);
  const checkFlag = await resolveCheckFlag(task.routeId, task.processId);
  await formApi.setValues({
    code,
    type: 1,
    feedbackTime: Date.now(),
    feedbackUserId: userStore.userInfo?.id,
    taskId: task.id,
    workOrderId: task.workOrderId,
    routeId: task.routeId,
    processId: task.processId,
    workstationId: task.workstationId,
    itemId: task.itemId,
    itemCode: task.itemCode,
    itemName: task.itemName,
    itemSpecification: task.itemSpecification,
    unitMeasureName: task.unitMeasureName,
    scheduledQuantity: task.quantity,
    checkFlag,
  });
  originalSnapshot.value = JSON.stringify(await getAlignedData());
}
/** ä¿å­˜ */
async function handleSave() {
  const { valid } = await formApi.validate();
  if (!valid) return;
  const data = await getAlignedData();
  if (formType.value === 'create') {
    const id = await createFeedback(data);
    feedbackId.value = id;
    feedbackStatus.value = MesProFeedbackStatusEnum.PREPARE;
    formType.value = 'update';
    formApi.setState({ schema: useFormSchema(formType.value, formApi) });
    await formApi.setFieldValue('id', id);
    message.success($t('common.createSuccess'));
  } else {
    await updateFeedback(data);
    message.success($t('common.updateSuccess'));
  }
  originalSnapshot.value = JSON.stringify(data);
  emit('reported');
}
/** æäº¤ */
async function handleSubmit() {
  const { valid } = await formApi.validate();
  if (!valid) return;
  const data = await getAlignedData();
  if (JSON.stringify(data) !== originalSnapshot.value) {
    await updateFeedback(data);
    originalSnapshot.value = JSON.stringify(data);
  }
  await submitFeedback(feedbackId.value!);
  message.success('报订单已提交');
  // é‡ç½®è¡¨å•为新建模式
  if (props.task) {
    await resetForTask(props.task);
  }
  emit('reported');
}
/** å®¡æ‰¹é€šè¿‡ */
async function handleApprove() {
  if (!feedbackId.value) return;
  const finished = await approveFeedback(feedbackId.value);
  message.success(
    finished ? '报订单已审批完成' : '报工成功,请等待质量检验完成!',
  );
  if (props.task) {
    await resetForTask(props.task);
  }
  emit('reported');
}
/** å®¡æ‰¹é©³å›ž */
async function handleReject() {
  if (!feedbackId.value) return;
  await rejectFeedback(feedbackId.value);
  message.success('报订单已驳回');
  if (props.task) {
    await resetForTask(props.task);
  }
  emit('reported');
}
/** åŠ è½½å·²æœ‰æŠ¥å·¥å•è¿›è¡Œç¼–è¾‘ */
async function loadFeedback(id: number) {
  const data = await getFeedback(id);
  feedbackId.value = data.id;
  feedbackStatus.value = data.status;
  formType.value = 'update';
  formApi.setState({ schema: useFormSchema(formType.value, formApi) });
  const checkFlag = await resolveCheckFlag(data.routeId, data.processId);
  await formApi.setValues({ ...data, checkFlag });
  originalSnapshot.value = JSON.stringify(await getAlignedData());
}
// ç›‘听任务变更
watch(
  () => props.task,
  (task) => {
    if (task) {
      resetForTask(task);
    }
  },
  { immediate: true },
);
// æš´éœ²æ–¹æ³•供外部调用
defineExpose({ loadFeedback, handleSave });
</script>
<template>
  <div class="feedback-tab">
    <div v-if="!task" class="feedback-tab__empty">
      è¯·ä»Žå·¦ä¾§å·¥å•列表选择一条任务
    </div>
    <template v-else>
      <div class="feedback-tab__header">
        <h3 class="feedback-tab__title">
          {{ formType === 'create' ? '新建报工' : '编辑报工' }}
        </h3>
        <span v-if="task.workOrderCode" class="feedback-tab__sub">
          å·¥å•: {{ task.workOrderCode }} {{ task.workOrderName }}
        </span>
      </div>
      <Form class="feedback-tab__form" />
      <div class="feedback-tab__actions">
        <Button
          v-if="isEditable"
          type="primary"
          @click="handleSave"
        >
          ä¿å­˜
        </Button>
        <Popconfirm
          v-if="canSubmit"
          title="确认提交该报订单?提交后将不能修改。"
          @confirm="handleSubmit"
        >
          <Button type="primary">{{ $t('common.submit') }}</Button>
        </Popconfirm>
        <Popconfirm
          v-if="canApprove"
          title="确认审批通过该报订单?"
          @confirm="handleApprove"
        >
          <Button type="primary">通过</Button>
        </Popconfirm>
        <Popconfirm
          v-if="canApprove"
          title="确认驳回该报订单?"
          @confirm="handleReject"
        >
          <Button danger>不通过</Button>
        </Popconfirm>
      </div>
    </template>
  </div>
</template>
<style lang="scss" scoped>
.feedback-tab {
  padding: 20px 24px;
  min-height: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  overflow: auto;
}
.feedback-tab__empty {
  padding: 64px 0;
  text-align: center;
  color: #8c8c8c;
  font-size: 16px;
}
.feedback-tab__header {
  display: flex;
  align-items: baseline;
  gap: 16px;
  margin-bottom: 20px;
  padding-bottom: 16px;
  border-bottom: 1px solid #f0f0f0;
}
.feedback-tab__title {
  margin: 0;
  font-size: 18px;
  font-weight: 600;
  color: #1a1a1a;
}
.feedback-tab__sub {
  font-size: 14px;
  color: #8c8c8c;
}
.feedback-tab__form {
  flex: 1;
}
.feedback-tab__actions {
  display: flex;
  gap: 12px;
  justify-content: flex-end;
  padding-top: 16px;
  border-top: 1px solid #f0f0f0;
  margin-top: 16px;
}
</style>
src/views/mes/workbench/components/tabs/ProductIssueTab.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,389 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmProductIssueApi } from '#/api/mes/wm/productissue';
import { h, markRaw, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue';
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
  MesWmProductIssueStatusEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { getDictOptions } from '@vben/hooks';
import { Button, Divider, message, Popconfirm, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import {
  cancelProductIssue,
  createProductIssue,
  deleteProductIssue,
  getProductIssuePage,
  submitProductIssue,
  updateProductIssue,
} from '#/api/mes/wm/productissue';
import { MdWorkstationSelect } from '#/views/mes/md/workstation/components';
import { ProWorkOrderSelect } from '#/views/mes/pro/workorder/components';
import { $t } from '#/locales';
defineOptions({ name: 'WorkbenchProductIssueTab' });
const props = defineProps<{
  /** å½“前工作站 ID */
  workstationId?: number;
  /** å½“前工作站名称 */
  workstationName?: string;
}>();
// --- è¡¨å•状态 ---
const editingId = ref<number>();
const isCreating = ref(false);
/** æž„建表单 schema */
function buildFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '领料单编号',
      component: 'Input',
      componentProps: { placeholder: '请输入领料单编号' },
      rules: 'required',
      suffix: () =>
        h(
          Button,
          {
            type: 'default',
            size: 'small',
            onClick: async () => {
              const code = await generateAutoCode(
                MesAutoCodeRuleCode.WM_PRODUCT_ISSUE_CODE,
              );
              await formApi.setFieldValue('code', code);
            },
          },
          { default: () => '生成' },
        ),
    },
    {
      fieldName: 'name',
      label: '领料单名称',
      component: 'Input',
      componentProps: { placeholder: '请输入领料单名称' },
      rules: 'required',
    },
    {
      fieldName: 'workOrderId',
      label: '生产订单',
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        status: MesProWorkOrderStatusEnum.CONFIRMED,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'workstationId',
      label: '工作站',
      component: markRaw(MdWorkstationSelect),
      componentProps: { placeholder: '请选择工作站' },
    },
    {
      fieldName: 'requiredTime',
      label: '需求时间',
      component: 'DatePicker',
      componentProps: {
        class: '!w-full',
        format: 'YYYY-MM-DD HH:mm:ss',
        placeholder: '请选择需求时间',
        showTime: true,
        valueFormat: 'x',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: { placeholder: '请输入备注', rows: 2 },
    },
  ];
}
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: buildFormSchema(),
  showDefaultActions: false,
  wrapperClass: 'grid-cols-3',
});
/** æ–°å»ºé¢†æ–™å• */
async function handleCreate() {
  isCreating.value = true;
  editingId.value = undefined;
  await formApi.resetForm();
  // é¢„填工作站
  if (props.workstationId) {
    await formApi.setFieldValue('workstationId', props.workstationId);
  }
}
/** ä¿å­˜é¢†æ–™å• */
async function handleSave() {
  const { valid } = await formApi.validate();
  if (!valid) return;
  const data =
    (await formApi.getValues()) as MesWmProductIssueApi.ProductIssue;
  try {
    if (editingId.value) {
      await updateProductIssue({ ...data, id: editingId.value });
      message.success('修改成功');
    } else {
      const id = await createProductIssue(data);
      editingId.value = id;
      await formApi.setFieldValue('id', id);
      message.success('创建成功');
    }
    isCreating.value = false;
    gridApi.query();
  } catch {
    message.error('保存失败');
  }
}
/** å–消编辑 */
function handleCancelEdit() {
  isCreating.value = false;
  editingId.value = undefined;
  formApi.resetForm();
}
/** æäº¤ */
async function handleSubmitRow(row: MesWmProductIssueApi.ProductIssue) {
  await submitProductIssue(row.id!);
  message.success('提交成功');
  gridApi.query();
}
/** åˆ é™¤ */
async function handleDeleteRow(row: MesWmProductIssueApi.ProductIssue) {
  await deleteProductIssue(row.id!);
  message.success('删除成功');
  gridApi.query();
}
/** å–消 */
async function handleCancelRow(row: MesWmProductIssueApi.ProductIssue) {
  await cancelProductIssue(row.id!);
  message.success('取消成功');
  gridApi.query();
}
// --- åˆ—表 ---
const gridColumns: VxeTableGridOptions<MesWmProductIssueApi.ProductIssue>['columns'] = [
  { field: 'code', title: '领料单编号', minWidth: 150 },
  { field: 'name', title: '领料单名称', minWidth: 130 },
  { field: 'workOrderCode', title: '生产订单', minWidth: 130 },
  {
    field: 'requiredTime',
    title: '需求时间',
    width: 170,
    formatter: 'formatDateTime',
  },
  {
    field: 'status',
    title: '状态',
    width: 100,
    cellRender: {
      name: 'CellDict',
      props: { type: DICT_TYPE.MES_WM_PRODUCT_ISSUE_STATUS },
    },
  },
  { title: '操作', width: 200, fixed: 'right', slots: { default: 'actions' } },
];
const gridHeight = ref(300);
const listRef = ref<HTMLElement>();
let resizeObserver: ResizeObserver | null = null;
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: gridColumns,
    height: gridHeight.value,
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getProductIssuePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            workstationId: props.workstationId,
            ...formValues,
          });
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true },
    pagerConfig: { pageSize: 10 },
  } as VxeTableGridOptions<MesWmProductIssueApi.ProductIssue>,
});
function updateGridHeight() {
  if (listRef.value) {
    const h = listRef.value.clientHeight;
    if (h > 100 && h !== gridHeight.value) {
      gridHeight.value = h;
      gridApi.setGridOptions({ height: h });
    }
  }
}
onMounted(() => {
  nextTick(() => {
    updateGridHeight();
    if (listRef.value) {
      resizeObserver = new ResizeObserver(() => updateGridHeight());
      resizeObserver.observe(listRef.value);
    }
  });
});
onBeforeUnmount(() => {
  resizeObserver?.disconnect();
  resizeObserver = null;
});
// å·¥ä½œç«™å˜åŒ–时刷新列表
watch(
  () => props.workstationId,
  () => gridApi.query(),
);
</script>
<template>
  <div class="issue-tab">
    <!-- é¡¶éƒ¨ï¼šè¡¨å• / æ–°å»ºæŒ‰é’® -->
    <div class="issue-tab__header">
      <div class="issue-tab__header-left">
        <IconifyIcon icon="lucide:clipboard-list" class="issue-tab__icon" />
        <span class="issue-tab__title">领料单</span>
        <Tag v-if="isCreating" color="blue">新建中</Tag>
      </div>
      <div class="issue-tab__header-actions">
        <template v-if="!isCreating">
          <Button type="primary" size="small" @click="handleCreate">
            <template #icon><IconifyIcon icon="lucide:plus" /></template>
            æ–°å»ºé¢†æ–™å•
          </Button>
        </template>
        <template v-else>
          <Button type="primary" size="small" @click="handleSave">
            <template #icon><IconifyIcon icon="lucide:save" /></template>
            ä¿å­˜
          </Button>
          <Button size="small" @click="handleCancelEdit">取消</Button>
        </template>
      </div>
    </div>
    <!-- è¡¨å•区域(仅新建时展示) -->
    <div v-if="isCreating" class="issue-tab__form">
      <Form />
    </div>
    <Divider style="margin: 12px 0" />
    <!-- åº•部:领料记录列表 -->
    <div ref="listRef" class="issue-tab__list">
      <Grid table-title="领料记录">
        <template #actions="{ row }">
          <Popconfirm
            v-if="row.status === MesWmProductIssueStatusEnum.PREPARE"
            title="确认提交?提交后不能修改"
            @confirm="handleSubmitRow(row)"
          >
            <Button type="link" size="small">提交</Button>
          </Popconfirm>
          <Popconfirm
            v-if="row.status === MesWmProductIssueStatusEnum.PREPARE"
            :title="`确认删除 ${row.code}?`"
            @confirm="handleDeleteRow(row)"
          >
            <Button type="link" size="small" danger>删除</Button>
          </Popconfirm>
          <Popconfirm
            v-if="
              row.status === MesWmProductIssueStatusEnum.APPROVING ||
              row.status === MesWmProductIssueStatusEnum.APPROVED
            "
            title="确认取消?取消后不可恢复"
            @confirm="handleCancelRow(row)"
          >
            <Button type="link" size="small" danger>取消</Button>
          </Popconfirm>
        </template>
      </Grid>
    </div>
  </div>
</template>
<style lang="scss" scoped>
.issue-tab {
  padding: 20px 24px;
  height: 100%;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.issue-tab__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.issue-tab__header-left {
  display: flex;
  align-items: center;
  gap: 8px;
}
.issue-tab__icon {
  font-size: 22px;
  color: #1890ff;
}
.issue-tab__title {
  font-size: 17px;
  font-weight: 600;
  color: #1a1a1a;
}
.issue-tab__header-actions {
  display: flex;
  gap: 8px;
}
.issue-tab__form {
  margin-top: 16px;
  padding: 16px;
  background: #fafbfc;
  border-radius: 10px;
  border: 1px solid #f0f0f0;
}
.issue-tab__list {
  flex: 1;
  min-height: 0;
  overflow: hidden;
}
</style>
src/views/mes/workbench/components/tabs/RecordParamTab.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,699 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { MesPdTemplateApi } from '#/api/mes/pro/processdesign/template';
import type { MesProWorkOrderParamRecordApi } from '#/api/mes/pro/workorderparamrecord';
import { nextTick, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Divider, message, Popconfirm, Tag } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { getTemplate } from '#/api/mes/pro/processdesign/template';
import {
  createWorkOrderParamRecord,
  deleteWorkOrderParamRecord,
  getWorkOrderParamRecord,
  getWorkOrderParamRecordPage,
  updateWorkOrderParamRecord,
} from '#/api/mes/pro/workorderparamrecord';
import TemplateSelectDialog from '../TemplateSelectDialog.vue';
defineOptions({ name: 'WorkbenchRecordParamTab' });
const props = defineProps<{
  /** å½“前工单编号 */
  workOrderId?: number;
}>();
// --- æ¨¡å¼ ---
type Mode = 'list' | 'create' | 'edit';
const mode = ref<Mode>('list');
const editingRecordId = ref<number>();
// --- æ¨¡æ¿çŠ¶æ€ ---
const selectedTemplate = ref<MesPdTemplateApi.Template>();
const templateDetails = ref<MesPdTemplateApi.TemplateDetail[]>([]);
const tplSelectDialogRef = ref<InstanceType<typeof TemplateSelectDialog>>();
function openTemplateSelect() {
  tplSelectDialogRef.value?.open();
}
/** é€‰æ‹©æ¨¡æ¿åŽåŠ è½½è¯¦æƒ… */
async function handleTemplateSelect(tpl: MesPdTemplateApi.Template) {
  if (!tpl.id) return;
  try {
    const detail = await getTemplate(tpl.id);
    selectedTemplate.value = detail;
    templateDetails.value = detail.details ?? [];
    await buildParamForm(templateDetails.value);
  } catch {
    message.error('加载模板详情失败');
  }
}
// --- åŠ¨æ€å‚æ•°è¡¨å• ---
const [ParamForm, paramFormApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 160,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
/** æ ¹æ®æ¨¡æ¿æ˜Žç»†æž„建参数表单 */
async function buildParamForm(
  details: MesPdTemplateApi.TemplateDetail[],
  existingValues?: Map<number, string>,
) {
  // å…ˆæž„建基础字段(recordNo, remark)
  const baseSchemas: VbenFormSchema[] = [
    {
      fieldName: '_recordNo',
      label: '记录序号',
      component: 'Input',
      componentProps: { placeholder: '请输入记录序号/批次号(可选)' },
    },
    {
      fieldName: '_remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: { placeholder: '请输入备注(可选)', rows: 2 },
    },
  ];
  if (!details || details.length === 0) {
    await paramFormApi.setState({ schema: baseSchemas });
    return;
  }
  const paramSchemas: VbenFormSchema[] = details
    .filter((d) => d != null)
    .map((d) => {
      const paramType = d.paramType ?? 1;
      const isRequired = d.required ?? false;
      const unitSuffix = d.unitMeasureName ? ` (${d.unitMeasureName})` : '';
      const paramName = d.paramName ?? '';
      const templateDetailId = d.id;
      let component = 'Input';
      const componentProps: Record<string, any> = {
        placeholder: `请输入${paramName}${unitSuffix}`,
        maxLength: 100,
      };
      switch (paramType) {
        case 1:
        case 2:
          component = 'InputNumber';
          componentProps.controls = false;
          componentProps.style = 'width: 100%';
          break;
        case 3:
          component = 'Select';
          componentProps.allowClear = true;
          componentProps.options = [];
          break;
        case 4:
          component = 'TimePicker';
          componentProps.format = 'HH:mm:ss';
          componentProps.valueFormat = 'HH:mm:ss';
          break;
        case 5:
          component = 'DatePicker';
          componentProps.format = 'YYYY-MM-DD';
          componentProps.valueFormat = 'YYYY-MM-DD';
          break;
      }
      let rules: any;
      if (isRequired) {
        if (paramType === 1 || paramType === 2) {
          rules = z.number({ message: `${paramName}不能为空` });
        } else {
          rules = z.string({ message: `${paramName}不能为空` }).min(1, `${paramName}不能为空`);
        }
      }
      return {
        fieldName: `param_${templateDetailId}`,
        label: `${paramName}${unitSuffix}`,
        component,
        componentProps,
        rules,
        defaultValue: existingValues?.get(templateDetailId!),
      } as VbenFormSchema;
    });
  await paramFormApi.resetForm();
  await nextTick();
  await paramFormApi.setState({ schema: [...baseSchemas, ...paramSchemas] });
}
/** æ–°å¢žå‚数记录 */
async function handleCreate() {
  mode.value = 'create';
  editingRecordId.value = undefined;
  selectedTemplate.value = undefined;
  templateDetails.value = [];
  await paramFormApi.setState({ schema: [] });
  await paramFormApi.resetForm();
}
/** æ‰“开模板选择 */
function handleSelectTemplate() {
  tplSelectDialogRef.value?.open();
}
/** å–消新增/编辑 */
function handleCancelForm() {
  mode.value = 'list';
  editingRecordId.value = undefined;
  selectedTemplate.value = undefined;
  templateDetails.value = [];
  paramFormApi.resetForm();
}
/** ä¿å­˜å‚数记录 */
async function handleSave() {
  if (!selectedTemplate.value || !props.workOrderId) return;
  const { valid } = await paramFormApi.validate();
  if (!valid) {
    message.warning('请检查必填参数');
    return;
  }
  const values = await paramFormApi.getValues();
  // æž„建 details æ•°ç»„
  const details = templateDetails.value
    .filter((d) => d != null && d.id != null)
    .map((d) => ({
      templateDetailId: d.id!,
      paramValue: values[`param_${d.id}`] != null ? String(values[`param_${d.id}`]) : undefined,
    }));
  // èŽ·å–è¡¨å•ä¸­çš„ recordNo å’Œ remark
  const recordNo = values._recordNo as string | undefined;
  const remark = values._remark as string | undefined;
  try {
    if (mode.value === 'edit' && editingRecordId.value) {
      await updateWorkOrderParamRecord({
        id: editingRecordId.value,
        templateId: selectedTemplate.value.id,
        recordNo,
        remark,
        details,
      });
      message.success('更新成功');
    } else {
      await createWorkOrderParamRecord(
        { templateId: selectedTemplate.value.id, recordNo, remark, details },
        props.workOrderId,
      );
      message.success('创建成功');
    }
    handleCancelForm();
    refreshRecordList();
  } catch {
    message.error('保存失败');
  }
}
/** ç¼–辑已有记录 */
async function handleEditRecord(recordId: number) {
  try {
    const record = await getWorkOrderParamRecord(recordId);
    if (!record.templateId) return;
    mode.value = 'edit';
    editingRecordId.value = record.id;
    // åŠ è½½æ¨¡æ¿
    const tpl = await getTemplate(record.templateId);
    selectedTemplate.value = tpl;
    templateDetails.value = tpl.details ?? [];
    // æž„建已有值映射
    const valueMap = new Map<number, string>();
    for (const d of record.details ?? []) {
      if (d.templateDetailId != null && d.paramValue != null) {
        valueMap.set(d.templateDetailId, d.paramValue);
      }
    }
    await buildParamForm(templateDetails.value, valueMap);
    // è®¾ç½® recordNo å’Œ remark
    await paramFormApi.setFieldValue('_recordNo', record.recordNo);
    await paramFormApi.setFieldValue('_remark', record.remark);
  } catch {
    message.error('加载记录详情失败');
  }
}
/** åˆ é™¤è®°å½• */
async function handleDeleteRecord(recordId: number) {
  try {
    await deleteWorkOrderParamRecord(recordId);
    message.success('删除成功');
    refreshRecordList();
  } catch {
    message.error('删除失败');
  }
}
// --- è®°å½•列表 ---
const recordList = ref<MesProWorkOrderParamRecordApi.WorkOrderParamRecord[]>([]);
const recordLoading = ref(false);
async function refreshRecordList() {
  if (!props.workOrderId) {
    recordList.value = [];
    return;
  }
  recordLoading.value = true;
  try {
    const result = await getWorkOrderParamRecordPage({
      workOrderId: props.workOrderId,
      pageNo: 1,
      pageSize: 100,
    });
    recordList.value = result.list ?? [];
  } finally {
    recordLoading.value = false;
  }
}
// å·¥å•变更时加载记录
watch(
  () => props.workOrderId,
  () => {
    handleCancelForm();
    refreshRecordList();
  },
);
/** æ ¼å¼åŒ–参数值展示 */
function formatParamDisplay(detail: MesProWorkOrderParamRecordApi.ParamRecordDetail) {
  const unit = detail.unitMeasureName ? ` ${detail.unitMeasureName}` : '';
  return `${detail.paramName}: ${detail.paramValue ?? '-'}${unit}`;
}
</script>
<template>
  <div class="record-param-tab">
    <!-- é¡¶éƒ¨æ“ä½œæ  -->
    <div class="record-param-tab__toolbar">
      <div class="record-param-tab__toolbar-left">
        <IconifyIcon icon="lucide:file-cog" class="record-param-tab__toolbar-icon" />
        <span class="record-param-tab__toolbar-title">生产记录参数</span>
      </div>
      <Button
        v-if="mode === 'list'"
        type="primary"
        size="small"
        :disabled="!workOrderId"
        @click="handleCreate"
      >
        <template #icon><IconifyIcon icon="lucide:plus" /></template>
        æ–°å¢žå‚数记录
      </Button>
      <div v-else class="record-param-tab__toolbar-actions">
        <Tag :color="mode === 'create' ? 'blue' : 'orange'">
          {{ mode === 'create' ? '新建' : '编辑' }}
        </Tag>
        <Button type="primary" size="small" @click="handleSave">
          <template #icon><IconifyIcon icon="lucide:save" /></template>
          ä¿å­˜
        </Button>
        <Button size="small" @click="handleCancelForm">取消</Button>
      </div>
    </div>
    <!-- è¡¨å•区域(新增/编辑模式) -->
    <div v-if="mode !== 'list'" class="record-param-tab__form-card">
      <!-- æ¨¡æ¿é€‰æ‹© -->
      <div class="record-param-tab__template-row">
        <span class="record-param-tab__template-label">参数模板:</span>
        <template v-if="selectedTemplate">
          <Tag color="green">{{ selectedTemplate.templateName }}</Tag>
          <span class="record-param-tab__template-code">{{ selectedTemplate.templateCode }}</span>
          <Button
            v-if="mode === 'create'"
            size="small"
            type="link"
            @click="handleSelectTemplate"
          >
            æ›´æ¢æ¨¡æ¿
          </Button>
        </template>
        <Button v-else type="dashed" size="small" @click="handleSelectTemplate">
          <template #icon><IconifyIcon icon="lucide:folder-open" /></template>
          é€‰æ‹©æ¨¡æ¿
        </Button>
      </div>
      <!-- è®°å½•编号 + å¤‡æ³¨ -->
      <div class="record-param-tab__meta-row">
        <ParamForm />
      </div>
      <!-- æ— æ¨¡æ¿æç¤º -->
      <div v-if="!selectedTemplate" class="record-param-tab__no-template">
        <IconifyIcon icon="lucide:arrow-up-left" class="record-param-tab__no-template-icon" />
        <span>请先选择一个参数模板</span>
      </div>
      <!-- å‚数表单已加载 -->
      <div
        v-else-if="templateDetails.length > 0"
        class="record-param-tab__param-info"
      >
        <span class="record-param-tab__param-count">
          å…± {{ templateDetails.length }} é¡¹å‚数,
          {{ templateDetails.filter((d) => d.required).length }} é¡¹å¿…å¡«
        </span>
      </div>
      <div v-else class="record-param-tab__no-template">
        <span>该模板暂无参数明细</span>
      </div>
    </div>
    <Divider style="margin: 12px 0" />
    <!-- è®°å½•列表 -->
    <div class="record-param-tab__list">
      <div class="record-param-tab__list-header">
        <span class="record-param-tab__list-title">
          å‚数记录
          <span class="record-param-tab__list-count">({{ recordList.length }})</span>
        </span>
        <Button
          v-if="workOrderId"
          size="small"
          :loading="recordLoading"
          @click="refreshRecordList"
        >
          <template #icon><IconifyIcon icon="lucide:refresh-cw" /></template>
          åˆ·æ–°
        </Button>
      </div>
      <!-- æ— å·¥å•提示 -->
      <div v-if="!workOrderId" class="record-param-tab__empty">
        <IconifyIcon icon="lucide:alert-circle" class="record-param-tab__empty-icon" />
        <p>请先从左侧选择一条工单任务</p>
      </div>
      <!-- ç©ºåˆ—表 -->
      <div v-else-if="recordList.length === 0" class="record-param-tab__empty">
        <IconifyIcon icon="lucide:inbox" class="record-param-tab__empty-icon" />
        <p>暂无参数记录,点击上方"新增参数记录"开始</p>
      </div>
      <!-- è®°å½•卡片列表 -->
      <div v-else class="record-param-tab__cards">
        <div
          v-for="record in recordList"
          :key="record.id"
          class="record-param-tab__card"
        >
          <div class="record-param-tab__card-head">
            <div class="record-param-tab__card-meta">
              <Tag color="green">{{ record.templateName }}</Tag>
              <span v-if="record.recordNo" class="record-param-tab__card-no">
                {{ record.recordNo }}
              </span>
              <span class="record-param-tab__card-time">{{ record.createTime }}</span>
            </div>
            <div class="record-param-tab__card-actions">
              <Button type="link" size="small" @click="handleEditRecord(record.id!)">
                ç¼–辑
              </Button>
              <Popconfirm
                title="确认删除该参数记录?删除后不可恢复"
                @confirm="handleDeleteRecord(record.id!)"
              >
                <Button type="link" size="small" danger>删除</Button>
              </Popconfirm>
            </div>
          </div>
          <div v-if="record.remark" class="record-param-tab__card-remark">
            å¤‡æ³¨ï¼š{{ record.remark }}
          </div>
          <div class="record-param-tab__card-params">
            <span
              v-for="d in record.details"
              :key="d.id"
              class="record-param-tab__param-tag"
            >
              {{ formatParamDisplay(d) }}
            </span>
          </div>
        </div>
      </div>
    </div>
    <!-- æ¨¡æ¿é€‰æ‹©å¼¹çª— -->
    <TemplateSelectDialog
      ref="tplSelectDialogRef"
      @select="handleTemplateSelect"
    />
  </div>
</template>
<style lang="scss" scoped>
.record-param-tab {
  padding: 20px 24px;
  height: 100%;
  display: flex;
  flex-direction: column;
  overflow: auto;
}
// --- é¡¶éƒ¨å·¥å…·æ  ---
.record-param-tab__toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-shrink: 0;
}
.record-param-tab__toolbar-left {
  display: flex;
  align-items: center;
  gap: 8px;
}
.record-param-tab__toolbar-icon {
  font-size: 22px;
  color: #22c55e;
}
.record-param-tab__toolbar-title {
  font-size: 17px;
  font-weight: 600;
  color: #1a1a1a;
}
.record-param-tab__toolbar-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}
// --- è¡¨å•卡片 ---
.record-param-tab__form-card {
  margin-top: 16px;
  padding: 16px 20px;
  background: #fafbfc;
  border-radius: 10px;
  border: 1px solid #e8e8e8;
  flex-shrink: 0;
}
.record-param-tab__template-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 12px;
}
.record-param-tab__template-label {
  font-size: 14px;
  font-weight: 500;
  color: #595959;
  flex-shrink: 0;
}
.record-param-tab__template-code {
  font-size: 12px;
  color: #8c8c8c;
}
.record-param-tab__meta-row {
  margin-top: 8px;
}
.record-param-tab__no-template {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 20px 0;
  color: #8c8c8c;
  font-size: 14px;
  justify-content: center;
}
.record-param-tab__no-template-icon {
  font-size: 18px;
  opacity: 0.6;
}
.record-param-tab__param-info {
  margin-top: 8px;
}
.record-param-tab__param-count {
  font-size: 13px;
  color: #8c8c8c;
}
// --- è®°å½•列表 ---
.record-param-tab__list {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.record-param-tab__list-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
  flex-shrink: 0;
}
.record-param-tab__list-title {
  font-size: 15px;
  font-weight: 600;
  color: #1a1a1a;
}
.record-param-tab__list-count {
  font-size: 13px;
  font-weight: 400;
  color: #8c8c8c;
  margin-left: 6px;
}
// --- ç©ºçŠ¶æ€ ---
.record-param-tab__empty {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  color: #8c8c8c;
  p {
    margin: 0;
    font-size: 14px;
  }
}
.record-param-tab__empty-icon {
  font-size: 36px;
  opacity: 0.4;
}
// --- è®°å½•卡片 ---
.record-param-tab__cards {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 10px;
  overflow: auto;
}
.record-param-tab__card {
  padding: 12px 16px;
  background: #fff;
  border: 1px solid #f0f0f0;
  border-radius: 8px;
  transition: box-shadow 0.2s;
  &:hover {
    box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
  }
}
.record-param-tab__card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.record-param-tab__card-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}
.record-param-tab__card-no {
  font-size: 13px;
  font-weight: 500;
  color: #595959;
  padding: 2px 8px;
  background: #f5f5f5;
  border-radius: 4px;
}
.record-param-tab__card-time {
  font-size: 12px;
  color: #8c8c8c;
}
.record-param-tab__card-actions {
  display: flex;
  gap: 4px;
  flex-shrink: 0;
}
.record-param-tab__card-remark {
  margin-top: 6px;
  font-size: 13px;
  color: #8c8c8c;
}
.record-param-tab__card-params {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 8px;
}
.record-param-tab__param-tag {
  display: inline-flex;
  align-items: center;
  padding: 3px 10px;
  border-radius: 4px;
  font-size: 12px;
  background: #f6ffed;
  color: #389e0d;
  border: 1px solid #d9f7be;
}
</style>
src/views/mes/workbench/components/tabs/ReturnIssueTab.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,409 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmReturnIssueApi } from '#/api/mes/wm/returnissue';
import { h, markRaw, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue';
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesProWorkOrderStatusEnum,
  MesWmReturnIssueStatusEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { getDictOptions } from '@vben/hooks';
import { Button, Divider, message, Popconfirm, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import {
  cancelReturnIssue,
  createReturnIssue,
  deleteReturnIssue,
  getReturnIssuePage,
  submitReturnIssue,
  updateReturnIssue,
} from '#/api/mes/wm/returnissue';
import { MdWorkstationSelect } from '#/views/mes/md/workstation/components';
import { ProWorkOrderSelect } from '#/views/mes/pro/workorder/components';
import { $t } from '#/locales';
defineOptions({ name: 'WorkbenchReturnIssueTab' });
const props = defineProps<{
  /** å½“前工作站 ID */
  workstationId?: number;
  /** å½“前工作站名称 */
  workstationName?: string;
}>();
// --- è¡¨å•状态 ---
const editingId = ref<number>();
const isCreating = ref(false);
/** æž„建表单 schema */
function buildFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '退料单编号',
      component: 'Input',
      componentProps: { placeholder: '请输入退料单编号' },
      rules: 'required',
      suffix: () =>
        h(
          Button,
          {
            type: 'default',
            size: 'small',
            onClick: async () => {
              const code = await generateAutoCode(
                MesAutoCodeRuleCode.WM_RETURN_ISSUE_CODE,
              );
              await formApi.setFieldValue('code', code);
            },
          },
          { default: () => '生成' },
        ),
    },
    {
      fieldName: 'name',
      label: '退料单名称',
      component: 'Input',
      componentProps: { placeholder: '请输入退料单名称' },
      rules: 'required',
    },
    {
      fieldName: 'type',
      label: '退料类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_WM_RETURN_ISSUE_TYPE, 'number'),
        placeholder: '请选择退料类型',
      },
      rules: 'required',
    },
    {
      fieldName: 'workOrderId',
      label: '生产订单',
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        status: MesProWorkOrderStatusEnum.CONFIRMED,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'workstationId',
      label: '工作站',
      component: markRaw(MdWorkstationSelect),
      componentProps: { placeholder: '请选择工作站' },
    },
    {
      fieldName: 'returnDate',
      label: '退料日期',
      component: 'DatePicker',
      componentProps: {
        class: '!w-full',
        format: 'YYYY-MM-DD HH:mm:ss',
        placeholder: '请选择退料日期',
        showTime: true,
        valueFormat: 'x',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: { placeholder: '请输入备注', rows: 2 },
    },
  ];
}
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: buildFormSchema(),
  showDefaultActions: false,
  wrapperClass: 'grid-cols-3',
});
/** æ–°å»ºé€€æ–™å• */
async function handleCreate() {
  isCreating.value = true;
  editingId.value = undefined;
  await formApi.resetForm();
  if (props.workstationId) {
    await formApi.setFieldValue('workstationId', props.workstationId);
  }
}
/** ä¿å­˜é€€æ–™å• */
async function handleSave() {
  const { valid } = await formApi.validate();
  if (!valid) return;
  const data =
    (await formApi.getValues()) as MesWmReturnIssueApi.ReturnIssue;
  try {
    if (editingId.value) {
      await updateReturnIssue({ ...data, id: editingId.value });
      message.success('修改成功');
    } else {
      const id = await createReturnIssue(data);
      editingId.value = id;
      await formApi.setFieldValue('id', id);
      message.success('创建成功');
    }
    isCreating.value = false;
    gridApi.query();
  } catch {
    message.error('保存失败');
  }
}
/** å–消编辑 */
function handleCancelEdit() {
  isCreating.value = false;
  editingId.value = undefined;
  formApi.resetForm();
}
/** æäº¤ */
async function handleSubmitRow(row: MesWmReturnIssueApi.ReturnIssue) {
  await submitReturnIssue(row.id!);
  message.success('提交成功');
  gridApi.query();
}
/** åˆ é™¤ */
async function handleDeleteRow(row: MesWmReturnIssueApi.ReturnIssue) {
  await deleteReturnIssue(row.id!);
  message.success('删除成功');
  gridApi.query();
}
/** å–消 */
async function handleCancelRow(row: MesWmReturnIssueApi.ReturnIssue) {
  await cancelReturnIssue(row.id!);
  message.success('取消成功');
  gridApi.query();
}
// --- åˆ—表 ---
const gridColumns: VxeTableGridOptions<MesWmReturnIssueApi.ReturnIssue>['columns'] = [
  { field: 'code', title: '退料单编号', minWidth: 150 },
  { field: 'name', title: '退料单名称', minWidth: 130 },
  {
    field: 'type',
    title: '退料类型',
    width: 100,
    cellRender: {
      name: 'CellDict',
      props: { type: DICT_TYPE.MES_WM_RETURN_ISSUE_TYPE },
    },
  },
  { field: 'workOrderCode', title: '生产订单', minWidth: 130 },
  {
    field: 'returnDate',
    title: '退料日期',
    width: 170,
    formatter: 'formatDateTime',
  },
  {
    field: 'status',
    title: '状态',
    width: 100,
    cellRender: {
      name: 'CellDict',
      props: { type: DICT_TYPE.MES_WM_RETURN_ISSUE_STATUS },
    },
  },
  { title: '操作', width: 200, fixed: 'right', slots: { default: 'actions' } },
];
const gridHeight = ref(300);
const listRef = ref<HTMLElement>();
let resizeObserver: ResizeObserver | null = null;
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: gridColumns,
    height: gridHeight.value,
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getReturnIssuePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            workstationId: props.workstationId,
            ...formValues,
          });
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true },
    pagerConfig: { pageSize: 10 },
  } as VxeTableGridOptions<MesWmReturnIssueApi.ReturnIssue>,
});
function updateGridHeight() {
  if (listRef.value) {
    const h = listRef.value.clientHeight;
    if (h > 100 && h !== gridHeight.value) {
      gridHeight.value = h;
      gridApi.setGridOptions({ height: h });
    }
  }
}
onMounted(() => {
  nextTick(() => {
    updateGridHeight();
    if (listRef.value) {
      resizeObserver = new ResizeObserver(() => updateGridHeight());
      resizeObserver.observe(listRef.value);
    }
  });
});
onBeforeUnmount(() => {
  resizeObserver?.disconnect();
  resizeObserver = null;
});
// å·¥ä½œç«™å˜åŒ–时刷新列表
watch(
  () => props.workstationId,
  () => gridApi.query(),
);
</script>
<template>
  <div class="issue-tab">
    <!-- é¡¶éƒ¨ï¼šè¡¨å• / æ–°å»ºæŒ‰é’® -->
    <div class="issue-tab__header">
      <div class="issue-tab__header-left">
        <IconifyIcon icon="lucide:rotate-ccw" class="issue-tab__icon" />
        <span class="issue-tab__title">退料单</span>
        <Tag v-if="isCreating" color="orange">新建中</Tag>
      </div>
      <div class="issue-tab__header-actions">
        <template v-if="!isCreating">
          <Button type="primary" size="small" @click="handleCreate">
            <template #icon><IconifyIcon icon="lucide:plus" /></template>
            æ–°å»ºé€€æ–™å•
          </Button>
        </template>
        <template v-else>
          <Button type="primary" size="small" @click="handleSave">
            <template #icon><IconifyIcon icon="lucide:save" /></template>
            ä¿å­˜
          </Button>
          <Button size="small" @click="handleCancelEdit">取消</Button>
        </template>
      </div>
    </div>
    <!-- è¡¨å•区域(仅新建时展示) -->
    <div v-if="isCreating" class="issue-tab__form">
      <Form />
    </div>
    <Divider style="margin: 12px 0" />
    <!-- åº•部:退料记录列表 -->
    <div ref="listRef" class="issue-tab__list">
      <Grid table-title="退料记录">
        <template #actions="{ row }">
          <Popconfirm
            v-if="row.status === MesWmReturnIssueStatusEnum.PREPARE"
            title="确认提交?提交后不能修改"
            @confirm="handleSubmitRow(row)"
          >
            <Button type="link" size="small">提交</Button>
          </Popconfirm>
          <Popconfirm
            v-if="row.status === MesWmReturnIssueStatusEnum.PREPARE"
            :title="`确认删除 ${row.code}?`"
            @confirm="handleDeleteRow(row)"
          >
            <Button type="link" size="small" danger>删除</Button>
          </Popconfirm>
          <Popconfirm
            v-if="
              row.status === MesWmReturnIssueStatusEnum.CONFIRMED ||
              row.status === MesWmReturnIssueStatusEnum.APPROVING ||
              row.status === MesWmReturnIssueStatusEnum.APPROVED
            "
            title="确认取消?取消后不可恢复"
            @confirm="handleCancelRow(row)"
          >
            <Button type="link" size="small" danger>取消</Button>
          </Popconfirm>
        </template>
      </Grid>
    </div>
  </div>
</template>
<style lang="scss" scoped>
.issue-tab {
  padding: 20px 24px;
  height: 100%;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.issue-tab__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.issue-tab__header-left {
  display: flex;
  align-items: center;
  gap: 8px;
}
.issue-tab__icon {
  font-size: 22px;
  color: #fa8c16;
}
.issue-tab__title {
  font-size: 17px;
  font-weight: 600;
  color: #1a1a1a;
}
.issue-tab__header-actions {
  display: flex;
  gap: 8px;
}
.issue-tab__form {
  margin-top: 16px;
  padding: 16px;
  background: #fafbfc;
  border-radius: 10px;
  border: 1px solid #f0f0f0;
}
.issue-tab__list {
  flex: 1;
  min-height: 0;
  overflow: hidden;
}
</style>
src/views/mes/workbench/components/tabs/TaskDetailTab.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,190 @@
<script lang="ts" setup>
import type { PendingTask } from '../../../types';
import { Descriptions, Progress, Tag } from 'ant-design-vue';
defineOptions({ name: 'WorkbenchTaskDetailTab' });
defineProps<{
  task?: PendingTask | null;
}>();
function getProgressStatus(rate?: number): 'exception' | 'normal' | 'success' {
  if (rate === undefined || rate === null) return 'normal';
  if (rate >= 100) return 'success';
  if (rate < 0) return 'exception';
  return 'normal';
}
</script>
<template>
  <div class="task-detail-tab">
    <div v-if="!task" class="task-detail-tab__empty">
      è¯·ä»Žå·¦ä¾§å·¥å•列表选择一条任务
    </div>
    <template v-else>
      <!-- è¿›åº¦æ¦‚览 -->
      <div class="detail-progress">
        <div class="detail-progress__head">
          <span class="detail-progress__title">报工进度</span>
          <span class="detail-progress__rate">{{ task.feedbackRate ?? 0 }}%</span>
          <span class="detail-progress__qty">
            {{ task.producedQuantity ?? 0 }} / {{ task.quantity ?? 0 }}
            {{ task.unitMeasureName }}
          </span>
        </div>
        <Progress
          :percent="task.feedbackRate ?? 0"
          :status="getProgressStatus(task.feedbackRate)"
          :stroke-color="{ from: '#108ee9', to: '#87d068' }"
          :show-info="false"
          :stroke-width="14"
        />
        <div class="detail-progress__stats">
          <div class="stat-item">
            <span class="stat-item__label">排产数量</span>
            <span class="stat-item__value">{{ task.quantity ?? 0 }}</span>
          </div>
          <div class="stat-item">
            <span class="stat-item__label">已生产</span>
            <span class="stat-item__value">{{ task.producedQuantity ?? 0 }}</span>
          </div>
          <div class="stat-item">
            <span class="stat-item__label">在途报工</span>
            <span class="stat-item__value">{{ task.inTransitQuantity ?? 0 }}</span>
          </div>
          <div class="stat-item">
            <span class="stat-item__label">可报工</span>
            <span class="stat-item__value stat-item__value--primary">
              {{ task.reportableQuantity ?? 0 }}
            </span>
          </div>
        </div>
      </div>
      <!-- è¯¦ç»†ä¿¡æ¯ -->
      <Descriptions
        bordered
        :column="2"
        size="middle"
        class="detail-desc"
      >
        <Descriptions.Item label="任务编码">{{ task.code ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="任务名称">{{ task.name ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="工单编码">{{ task.workOrderCode ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="工单名称">{{ task.workOrderName ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="工作站">{{ task.workstationName ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="工序">{{ task.processName ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="产品编码">{{ task.itemCode ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="产品名称">{{ task.itemName ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="规格型号">{{ task.itemSpecification ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="计量单位">{{ task.unitMeasureName ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="合格品数量">{{ task.qualifyQuantity ?? 0 }}</Descriptions.Item>
        <Descriptions.Item label="不良品数量">{{ task.unqualifyQuantity ?? 0 }}</Descriptions.Item>
        <Descriptions.Item label="客户名称">{{ task.clientName ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="是否质检工序">
          <Tag v-if="task.checkFlag" color="orange">是</Tag>
          <Tag v-else>否</Tag>
        </Descriptions.Item>
        <Descriptions.Item label="需求日期" :span="2">
          {{ task.requestDate ?? '—' }}
        </Descriptions.Item>
        <Descriptions.Item label="备注" :span="2">{{ task.remark ?? '—' }}</Descriptions.Item>
      </Descriptions>
    </template>
  </div>
</template>
<style lang="scss" scoped>
.task-detail-tab {
  padding: 20px 24px;
  min-height: 100%;
  height: 100%;
  overflow: auto;
}
.task-detail-tab__empty {
  padding: 64px 0;
  text-align: center;
  color: #8c8c8c;
  font-size: 16px;
}
.detail-progress {
  margin-bottom: 20px;
  padding: 16px 20px;
  border-radius: 8px;
  background: #f6f8fa;
  border: 1px solid #e8e8e8;
}
.detail-progress__head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 8px 16px;
  margin-bottom: 12px;
}
.detail-progress__title {
  font-size: 16px;
  font-weight: 600;
  color: #1a1a1a;
}
.detail-progress__rate {
  font-size: 22px;
  font-weight: 700;
  color: var(--ant-primary-color, #1890ff);
  font-variant-numeric: tabular-nums;
}
.detail-progress__qty {
  margin-left: auto;
  font-size: 14px;
  font-weight: 600;
  color: #595959;
}
.detail-progress__stats {
  display: flex;
  gap: 24px;
  margin-top: 14px;
  flex-wrap: wrap;
}
.stat-item {
  display: flex;
  flex-direction: column;
  gap: 4px;
  &__label {
    font-size: 12px;
    color: #8c8c8c;
    font-weight: 500;
  }
  &__value {
    font-size: 18px;
    font-weight: 700;
    color: #1a1a1a;
    font-variant-numeric: tabular-nums;
    &--primary {
      color: var(--ant-primary-color, #1890ff);
    }
  }
}
.detail-desc {
  :deep(.ant-descriptions-item-label) {
    width: 140px;
    font-weight: 600;
    background: #fafafa;
  }
  :deep(.ant-descriptions-item-content) {
    font-size: 14px;
  }
}
</style>
src/views/mes/workbench/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,430 @@
<script lang="ts" setup>
import type { MesMdWorkstationApi } from '#/api/mes/md/workstation';
import type { PendingTask, WorkbenchTabItem, WorkbenchTabKey } from './types';
import { computed, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button } from 'ant-design-vue';
import { getPendingFeedbackPage } from '#/api/mes/pro/task';
import { router } from '#/router';
import TaskListPanel from './components/TaskListPanel.vue';
import WorkstationPanel from './components/WorkstationPanel.vue';
import WorkstationSelectDialog from './components/WorkstationSelectDialog.vue';
import FeedbackHistoryTab from './components/tabs/FeedbackHistoryTab.vue';
import FeedbackTab from './components/tabs/FeedbackTab.vue';
import ProductIssueTab from './components/tabs/ProductIssueTab.vue';
import RecordParamTab from './components/tabs/RecordParamTab.vue';
import ReturnIssueTab from './components/tabs/ReturnIssueTab.vue';
import TaskDetailTab from './components/tabs/TaskDetailTab.vue';
defineOptions({ name: 'MesWorkbench' });
// --- Tab é…ç½® ---
const tabItems: WorkbenchTabItem[] = [
  { key: 'detail', label: '工单详情', icon: 'lucide:file-text' },
  { key: 'feedback', label: '报工', icon: 'lucide:edit-3' },
  { key: 'history', label: '报工记录', icon: 'lucide:history' },
  { key: 'record-param', label: '生产记录参数', icon: 'lucide:file-cog' },
  { key: 'product-issue', label: '领料', icon: 'lucide:clipboard-list' },
  { key: 'return-issue', label: '退料', icon: 'lucide:rotate-ccw' },
];
const activeTab = ref<WorkbenchTabKey>('detail');
const activeTabLabel = computed(
  () => tabItems.find((t) => t.key === activeTab.value)?.label ?? '',
);
// --- å·¥ä½œç«™ ---
const workstation = ref<MesMdWorkstationApi.Workstation>();
const workstationId = computed(() => workstation.value?.id);
const workstationName = computed(() => workstation.value?.name ?? '');
const wsSelectDialogRef = ref<InstanceType<typeof WorkstationSelectDialog>>();
function openWorkstationSelect() {
  wsSelectDialogRef.value?.open();
}
function handleWorkstationSelect(ws: MesMdWorkstationApi.Workstation) {
  workstation.value = ws;
}
// --- ä»»åŠ¡åˆ—è¡¨ ---
const taskList = ref<PendingTask[]>([]);
const taskLoading = ref(false);
const selectedTask = ref<PendingTask>();
async function loadTaskList() {
  if (!workstationId.value) {
    taskList.value = [];
    selectedTask.value = undefined;
    return;
  }
  taskLoading.value = true;
  try {
    const result = await getPendingFeedbackPage({
      workstationId: workstationId.value,
      pageNo: 1,
      pageSize: 200,
    });
    taskList.value = result.list ?? [];
    // è‡ªåŠ¨é€‰ä¸­å¹¶åŒæ­¥ selectedTask åˆ°æœ€æ–°æ•°æ®
    if (taskList.value.length) {
      if (selectedTask.value) {
        // å°è¯•在新列表中找到同 ID çš„任务,更新引用以刷新数据
        const updated = taskList.value.find(
          (t) => t.id === selectedTask.value!.id,
        );
        selectedTask.value = updated ?? taskList.value[0];
      } else {
        selectedTask.value = taskList.value[0];
      }
    } else {
      selectedTask.value = undefined;
    }
  } finally {
    taskLoading.value = false;
  }
}
// å·¥ä½œç«™å˜æ›´æ—¶é‡æ–°åŠ è½½ä»»åŠ¡åˆ—è¡¨
watch(workstationId, () => {
  loadTaskList();
});
function handleSelectTask(task: PendingTask) {
  selectedTask.value = task;
}
// --- æŠ¥å·¥åˆ·æ–° ---
const reportRefreshKey = ref(0);
function onReported() {
  reportRefreshKey.value += 1;
  // åˆ·æ–°ä»»åŠ¡åˆ—è¡¨ä»¥æ›´æ–°è¿›åº¦
  loadTaskList();
}
// --- è¿”回系统 ---
function goBack() {
  router.push('/');
}
</script>
<template>
  <div class="workbench-page">
    <!-- é¡¶éƒ¨å¯¼èˆªæ  -->
    <header class="workbench-topbar">
      <div class="workbench-topbar__brand">
        <IconifyIcon icon="lucide:factory" class="workbench-topbar__logo" />
        <div class="workbench-topbar__brand-text">
          <span class="workbench-topbar__title">生产工作台</span>
          <span v-if="workstationName" class="workbench-topbar__sub">
            {{ workstationName }}
          </span>
        </div>
      </div>
      <nav class="workbench-nav">
        <button
          v-for="tab in tabItems"
          :key="tab.key"
          type="button"
          class="workbench-nav__item"
          :class="{ 'workbench-nav__item--active': activeTab === tab.key }"
          @click="activeTab = tab.key"
        >
          <IconifyIcon :icon="tab.icon" class="workbench-nav__icon" />
          {{ tab.label }}
        </button>
      </nav>
      <Button class="workbench-back-btn" @click="goBack">
        <template #icon>
          <IconifyIcon icon="lucide:arrow-left" />
        </template>
        è¿”回系统
      </Button>
    </header>
    <!-- ä¸»ä½“区域 -->
    <div class="workbench-body">
      <!-- å·¦ä¾§è¾¹æ  -->
      <aside class="workbench-aside">
        <!-- å·¥ä½œç«™é€‰æ‹©é¢æ¿ -->
        <WorkstationPanel
          :workstation="workstation"
          @change="openWorkstationSelect"
        />
        <!-- å·¥å•列表 -->
        <TaskListPanel
          :list="taskList"
          :selected-id="selectedTask?.id"
          :loading="taskLoading"
          @select="handleSelectTask"
        />
      </aside>
      <!-- åˆ†å‰²çº¿æ‹–拽按钮(视觉装饰) -->
      <div class="workbench-split">
        <div class="workbench-split__line" />
      </div>
      <!-- å³ä¾§ä¸»åŒºåŸŸ -->
      <main class="workbench-main">
        <div class="workbench-main__head">
          <span class="workbench-main__tab-title">{{ activeTabLabel }}</span>
          <span v-if="selectedTask?.workOrderCode" class="workbench-main__context">
            å·¥å• {{ selectedTask.workOrderCode }}
          </span>
        </div>
        <div class="workbench-main__body">
          <TaskDetailTab
            v-if="activeTab === 'detail'"
            :task="selectedTask"
          />
          <FeedbackTab
            v-else-if="activeTab === 'feedback'"
            :task="selectedTask"
            :refresh-key="reportRefreshKey"
            @reported="onReported"
          />
          <FeedbackHistoryTab
            v-else-if="activeTab === 'history'"
            :task-id="selectedTask?.id"
            :refresh-key="reportRefreshKey"
            @reported="onReported"
          />
          <RecordParamTab
            v-else-if="activeTab === 'record-param'"
            :work-order-id="selectedTask?.workOrderId"
          />
          <ProductIssueTab
            v-else-if="activeTab === 'product-issue'"
            :workstation-id="workstationId"
            :workstation-name="workstationName"
          />
          <ReturnIssueTab
            v-else-if="activeTab === 'return-issue'"
            :workstation-id="workstationId"
            :workstation-name="workstationName"
          />
        </div>
      </main>
    </div>
    <!-- å·¥ä½œç«™é€‰æ‹©å¼¹çª— -->
    <WorkstationSelectDialog
      ref="wsSelectDialogRef"
      @select="handleWorkstationSelect"
    />
  </div>
</template>
<style lang="scss" scoped>
$radius: 10px;
$shadow: 0 2px 12px rgb(0 0 0 / 8%);
$shadow-sm: 0 1px 4px rgb(0 0 0 / 6%);
$aside-width: 420px;
.workbench-page {
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 100vh;
  overflow: hidden;
  background: #f5f5f5;
}
// --- é¡¶éƒ¨å¯¼èˆªæ  ---
.workbench-topbar {
  display: flex;
  align-items: flex-end;
  gap: 20px;
  flex-shrink: 0;
  min-height: 56px;
  padding: 0 20px;
  background: linear-gradient(
    105deg,
    #1a3a5c 0%,
    #1890ff 42%,
    #40a9ff 100%
  );
  color: #fff;
  box-shadow: $shadow;
  z-index: 10;
}
.workbench-topbar__brand {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-shrink: 0;
  padding-bottom: 10px;
}
.workbench-topbar__logo {
  font-size: 28px;
  filter: brightness(1.1);
}
.workbench-topbar__brand-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.workbench-topbar__title {
  font-size: 18px;
  font-weight: 600;
  letter-spacing: 0.02em;
}
.workbench-topbar__sub {
  font-size: 13px;
  opacity: 0.85;
}
.workbench-nav {
  flex: 1;
  display: flex;
  align-items: flex-end;
  gap: 4px;
  min-width: 0;
  padding-bottom: 0;
  overflow-x: auto;
  scrollbar-width: none;
  &::-webkit-scrollbar {
    display: none;
  }
}
.workbench-nav__item {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 10px 20px;
  border: none;
  border-radius: 8px 8px 0 0;
  background: transparent;
  color: rgb(255 255 255 / 75%);
  font-size: 15px;
  cursor: pointer;
  transition: all 0.2s ease;
  &:hover:not(&--active) {
    color: #fff;
    background: rgb(255 255 255 / 12%);
  }
  &--active {
    color: #1890ff;
    background: #f5f5f5;
    font-weight: 600;
    box-shadow: 0 -2px 8px rgb(0 0 0 / 6%);
  }
}
.workbench-nav__icon {
  font-size: 18px;
}
.workbench-back-btn {
  flex-shrink: 0;
  margin-bottom: 8px;
  border-color: rgb(255 255 255 / 35%) !important;
  background: rgb(255 255 255 / 12%) !important;
  color: #fff !important;
  &:hover {
    border-color: rgb(255 255 255 / 55%) !important;
    background: rgb(255 255 255 / 22%) !important;
  }
}
// --- ä¸»ä½“布局 ---
.workbench-body {
  flex: 1;
  min-height: 0;
  display: flex;
  align-items: stretch;
  padding: 12px;
  gap: 0;
}
.workbench-aside {
  flex-shrink: 0;
  width: $aside-width;
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-height: 0;
  overflow: hidden;
}
.workbench-split {
  flex-shrink: 0;
  width: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.workbench-split__line {
  width: 1px;
  height: 100%;
  background: #e8e8e8;
}
.workbench-main {
  flex: 1;
  margin-left: 12px;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: #fff;
  border-radius: $radius;
  border: 1px solid #f0f0f0;
  box-shadow: $shadow-sm;
  overflow: hidden;
}
.workbench-main__head {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-shrink: 0;
  padding: 14px 24px;
  border-bottom: 1px solid #f0f0f0;
  background: linear-gradient(180deg, #fafbfc 0%, #fff 100%);
}
.workbench-main__tab-title {
  font-size: 18px;
  font-weight: 600;
  color: #1890ff;
}
.workbench-main__context {
  font-size: 14px;
  color: #595959;
  padding: 4px 12px;
  background: #f0f0f0;
  border-radius: 6px;
  font-weight: 500;
}
.workbench-main__body {
  flex: 1;
  min-height: 0;
  overflow: hidden;
}
</style>
src/views/mes/workbench/types.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
import type { MesProTaskApi } from '#/api/mes/pro/task';
/** å·¥ä½œå° Tab é¡µé”® */
export type WorkbenchTabKey = 'detail' | 'feedback' | 'history' | 'record-param' | 'product-issue' | 'return-issue';
/** å·¥ä½œå° Tab é¡¹ */
export interface WorkbenchTabItem {
  key: WorkbenchTabKey;
  label: string;
  icon: string;
}
/** å¾…报工任务(从 API ç±»åž‹é‡å¯¼å‡ºï¼Œä¾¿äºŽç»„件引用) */
export type PendingTask = MesProTaskApi.PendingFeedbackTask;