From 168c5f65c936eb707ea232057e808cf940a0c255 Mon Sep 17 00:00:00 2001
From: zhangwencui <1064582902@qq.com>
Date: 星期三, 15 七月 2026 17:53:23 +0800
Subject: [PATCH] 生产工作台功能开发
---
src/views/mes/workbench/index.vue | 430 ++++++
src/views/mes/workbench/components/tabs/ReturnIssueTab.vue | 409 ++++++
src/api/mes/pro/task/index.ts | 30
src/views/mes/workbench/types.ts | 14
src/views/mes/workbench/components/TaskListPanel.vue | 353 +++++
src/views/mes/workbench/components/TemplateSelectDialog.vue | 262 +++
src/views/mes/workbench/components/tabs/ProductIssueTab.vue | 389 +++++
src/views/mes/workbench/components/tabs/FeedbackHistoryTab.vue | 292 ++++
src/views/mes/workbench/components/tabs/FeedbackTab.vue | 331 ++++
src/layouts/basic.vue | 10
src/views/mes/workbench/components/WorkstationPanel.vue | 265 +++
src/api/mes/pro/feedback/index.ts | 7
src/router/routes/core.ts | 15
src/views/mes/workbench/components/WorkstationSelectDialog.vue | 258 +++
src/views/mes/workbench/components/tabs/RecordParamTab.vue | 699 ++++++++++
src/api/mes/pro/workorderparamrecord/index.ts | 88 +
src/views/mes/workbench/components/tabs/TaskDetailTab.vue | 190 ++
17 files changed, 4,042 insertions(+), 0 deletions(-)
diff --git a/src/api/mes/pro/feedback/index.ts b/src/api/mes/pro/feedback/index.ts
index 0536c94..fe7605d 100644
--- a/src/api/mes/pro/feedback/index.ts
+++ b/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}`,
+ );
+}
diff --git a/src/api/mes/pro/task/index.ts b/src/api/mes/pro/task/index.ts
index e8f6dec..2dba85e 100644
--- a/src/api/mes/pro/task/index.ts
+++ b/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 },
+ );
+}
diff --git a/src/api/mes/pro/workorderparamrecord/index.ts b/src/api/mes/pro/workorderparamrecord/index.ts
new file mode 100644
index 0000000..e54b717
--- /dev/null
+++ b/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}`,
+ );
+}
diff --git a/src/layouts/basic.vue b/src/layouts/basic.vue
index b6231e5..ee2b152 100644
--- a/src/layouts/basic.vue
+++ b/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"
diff --git a/src/router/routes/core.ts b/src/router/routes/core.ts
index f9aec30..7e14b88 100644
--- a/src/router/routes/core.ts
+++ b/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 鐨勫祵鍏�
*/
{
diff --git a/src/views/mes/workbench/components/TaskListPanel.vue b/src/views/mes/workbench/components/TaskListPanel.vue
new file mode 100644
index 0000000..24d1f72
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/workbench/components/TemplateSelectDialog.vue b/src/views/mes/workbench/components/TemplateSelectDialog.vue
new file mode 100644
index 0000000..d304ec6
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/workbench/components/WorkstationPanel.vue b/src/views/mes/workbench/components/WorkstationPanel.vue
new file mode 100644
index 0000000..eb93fcb
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/workbench/components/WorkstationSelectDialog.vue b/src/views/mes/workbench/components/WorkstationSelectDialog.vue
new file mode 100644
index 0000000..6d5e41d
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/workbench/components/tabs/FeedbackHistoryTab.vue b/src/views/mes/workbench/components/tabs/FeedbackHistoryTab.vue
new file mode 100644
index 0000000..578eaf9
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/workbench/components/tabs/FeedbackTab.vue b/src/views/mes/workbench/components/tabs/FeedbackTab.vue
new file mode 100644
index 0000000..367c822
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/workbench/components/tabs/ProductIssueTab.vue b/src/views/mes/workbench/components/tabs/ProductIssueTab.vue
new file mode 100644
index 0000000..e59ad60
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/workbench/components/tabs/RecordParamTab.vue b/src/views/mes/workbench/components/tabs/RecordParamTab.vue
new file mode 100644
index 0000000..7ecea3a
--- /dev/null
+++ b/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>,
+) {
+ // 鍏堟瀯寤哄熀纭�瀛楁锛坮ecordNo, 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>
diff --git a/src/views/mes/workbench/components/tabs/ReturnIssueTab.vue b/src/views/mes/workbench/components/tabs/ReturnIssueTab.vue
new file mode 100644
index 0000000..50ca5ef
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/workbench/components/tabs/TaskDetailTab.vue b/src/views/mes/workbench/components/tabs/TaskDetailTab.vue
new file mode 100644
index 0000000..68e1a68
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/workbench/index.vue b/src/views/mes/workbench/index.vue
new file mode 100644
index 0000000..4fc3302
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/workbench/types.ts b/src/views/mes/workbench/types.ts
new file mode 100644
index 0000000..8f74ad3
--- /dev/null
+++ b/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;
--
Gitblit v1.9.3