7 小时以前 5960d731989c58e887b6cca103edfe23dec5e06b
feat(workorder): 添加工单工序管理功能并优化API服务

- 添加工单工序相关API接口定义(批量保存工序、工单进度详情等)
- 实现工序列表组件及字段配置功能
- 集成工序管理到工单表单中,支持工序的增删改查
- 添加快速报工和批量报工功能模块
- 实现一键排产预览和执行功能
- 更新依赖包配置和锁定文件
- 重构API服务模式文档,规范接口定义方式
已添加13个文件
已修改13个文件
3131 ■■■■■ 文件已修改
.claude/rules/typescript.md 135 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.claude/rules/vue-component.md 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.claude/settings.local.json 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.claude/skills/api-service-pattern.md 213 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.claude/skills/vben-table-form.md 421 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.claude/skills/vue-component-patterns.md 170 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
CLAUDE.md 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
internal/node-utils/package.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pnpm-lock.yaml 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/feedback/index.ts 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/task/index.ts 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/index.ts 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/process/index.ts 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/file-preview/file-preview-modal.vue 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/index.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/modules/batch-feedback.vue 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/index.vue 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder-center/data.ts 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder-center/index.vue 313 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder-center/modules/quick-feedback.vue 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder-center/modules/schedule-preview.vue 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/data.ts 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/form.vue 103 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-form.vue 319 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-list.vue 236 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.claude/rules/typescript.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,135 @@
---
paths:
  - "**/*.ts"
  - "**/*.vue"
---
# TypeScript å‰ç«¯å¼€å‘规则
> åŸºäºŽ ECC TypeScript è§„则扩展,适配 Vue Vben Admin é¡¹ç›®ã€‚
## ç±»åž‹å®šä¹‰
### ç¦æ­¢ any
```typescript
// âŒ é”™è¯¯ï¼šä½¿ç”¨ any
function handleData(data: any) {
  return data.value;
}
// âœ… æ­£ç¡®ï¼šä½¿ç”¨å…·ä½“类型
interface DataType {
  value: string;
}
function handleData(data: DataType) {
  return data.value;
}
// âœ… æ­£ç¡®ï¼šä½¿ç”¨ unknown + ç±»åž‹å®ˆå«
function getErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }
  return '未知错误';
}
```
### import type
```typescript
// âœ… æ­£ç¡®ï¼šç±»åž‹å¯¼å…¥ä½¿ç”¨ import type
import type { UserInfo } from '#/api/system/user';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
// âŒ é”™è¯¯ï¼šæ™®é€šå¯¼å…¥ç±»åž‹
import { UserInfo } from '#/api/system/user';
```
### æŽ¥å£å®šä¹‰
```typescript
// âœ… æ­£ç¡®ï¼šå®žä½“接口
export namespace UserApi {
  export interface User {
    id?: number;
    username: string;
    nickname?: string;
    status: number;
  }
}
// âœ… æ­£ç¡®ï¼šProps æŽ¥å£
interface Props {
  userId: number;
  userName?: string;
}
const props = withDefaults(defineProps<Props>(), {
  userName: '',
});
```
## ä¸å¯å˜æ€§
```typescript
// âŒ é”™è¯¯ï¼šç›´æŽ¥ä¿®æ”¹ props
props.userName = 'new name';
// âœ… æ­£ç¡®ï¼šä½¿ç”¨ emit æ›´æ–°
emit('update:userName', 'new name');
// âœ… æ­£ç¡®ï¼šä½¿ç”¨å±•开运算符
const newUser = { ...user, name: 'new name' };
```
## é”™è¯¯å¤„理
```typescript
// âœ… æ­£ç¡®ï¼šasync/await + try-catch
async function loadUser(id: number) {
  try {
    const user = await getUser(id);
    return user;
  } catch (error: unknown) {
    if (error instanceof Error) {
      message.error(error.message);
    }
    throw error;
  }
}
```
## è·¯å¾„别名
```typescript
// âœ… æ­£ç¡®ï¼šä½¿ç”¨ # åˆ«å
import { getUser } from '#/api/system/user';
import DictTag from '#/components/dict-tag/dict-tag.vue';
// âŒ é”™è¯¯ï¼šä½¿ç”¨ç›¸å¯¹è·¯å¾„
import { getUser } from '../../../api/system/user';
```
## ç»„件命名
```typescript
// âœ… æ­£ç¡®ï¼šç»„件文件名 PascalCase
UserList.vue
DictTag.vue
TableAction.vue
// âœ… æ­£ç¡®ï¼šå®šä¹‰ç»„件名
defineOptions({ name: 'UserList' });
```
## æŽ§åˆ¶å°è¾“出
```typescript
// âŒ ç¦æ­¢ï¼šconsole.log åœ¨ç”Ÿäº§ä»£ç ä¸­
console.log('debug info');
// âœ… æ­£ç¡®ï¼šä½¿ç”¨æ—¥å¿—库或移除
// ç”Ÿäº§çŽ¯å¢ƒç§»é™¤æ‰€æœ‰è°ƒè¯•è¾“å‡º
```
.claude/rules/vue-component.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,166 @@
---
paths:
  - "**/*.vue"
---
# Vue ç»„件开发规则
> åŸºäºŽ Vue Vben Admin é¡¹ç›®è§„范。
## ç»„件结构
### script setup
```vue
<script lang="ts" setup>
// 1. ç»„件命名(必须)
defineOptions({ name: 'ComponentName' });
// 2. ç±»åž‹å¯¼å…¥
import type { PropType } from './types';
// 3. Props å®šä¹‰
interface Props {
  id: number;
  name?: string;
}
const props = withDefaults(defineProps<Props>(), {
  name: '',
});
// 4. Emits å®šä¹‰
interface Emits {
  (e: 'update', value: string): void;
  (e: 'delete', id: number): void;
}
const emit = defineEmits<Emits>();
// 5. å“åº”式状态
const loading = ref(false);
// 6. è®¡ç®—属性
const displayName = computed(() => props.name || '未命名');
// 7. æ–¹æ³•
function handleSubmit() {
  emit('update', displayName.value);
}
</script>
<template>
  <div class="component-name">
    <!-- æ¨¡æ¿å†…容 -->
  </div>
</template>
<style lang="scss" scoped>
.component-name {
  /* æ ·å¼ */
}
</style>
```
## Props è§„范
```vue
<script lang="ts" setup>
// âœ… æ­£ç¡®ï¼šä½¿ç”¨ withDefaults + defineProps
interface Props {
  userId: number;
  userName?: string;
}
const props = withDefaults(defineProps<Props>(), {
  userName: '',
});
// âŒ é”™è¯¯ï¼šä½¿ç”¨è¿è¡Œæ—¶å£°æ˜Ž
const props = defineProps({
  userId: { type: Number, required: true },
  userName: { type: String, default: '' },
});
</script>
```
## Emits è§„范
```vue
<script lang="ts" setup>
// âœ… æ­£ç¡®ï¼šç±»åž‹åŒ– Emits
interface Emits {
  (e: 'update', value: string): void;
  (e: 'delete', id: number): void;
  (e: 'change', data: { id: number; name: string }): void;
}
const emit = defineEmits<Emits>();
// âŒ é”™è¯¯ï¼šè¿è¡Œæ—¶å£°æ˜Ž
const emit = defineEmits(['update', 'delete']);
</script>
```
## æ ·å¼è§„范
```vue
<template>
  <!-- âœ… æ­£ç¡®ï¼šä¼˜å…ˆä½¿ç”¨ Tailwind CSS -->
  <div class="flex items-center p-4 bg-white rounded-lg">
    <span class="text-base font-medium text-gray-900">标题</span>
  </div>
</template>
<style lang="scss" scoped>
/* âœ… æ­£ç¡®ï¼šç»„件样式使用 scoped */
.component-name {
  /* âœ… æ­£ç¡®ï¼šé¢œè‰²ä½¿ç”¨ CSS å˜é‡ */
  background-color: var(--component-background);
  /* âœ… æ­£ç¡®ï¼šè¦†ç›–组件库样式使用 :deep */
  :deep(.ant-table) {
    border-radius: 8px;
  }
}
/* âŒ é”™è¯¯ï¼šç¡¬ç¼–码颜色 */
.bad-style {
  background-color: #ffffff;
}
</style>
```
## ç»„件命名
| ç±»åž‹ | å‘½åè§„则 | ç¤ºä¾‹ |
|-----|---------|-----|
| é¡µé¢ç»„ä»¶ | ä¸Žç›®å½•名一致 | `UserList.vue` â†’ `name: 'UserList'` |
| è¡¨å•弹窗 | åŠŸèƒ½ + Form | `UserForm.vue` â†’ `name: 'UserForm'` |
| è¯¦æƒ…弹窗 | åŠŸèƒ½ + Detail | `UserDetail.vue` â†’ `name: 'UserDetail'` |
| é€‰æ‹©å™¨ | åŠŸèƒ½ + Select | `UserSelect.vue` â†’ `name: 'UserSelect'` |
| åˆ—表组件 | åŠŸèƒ½ + List | `UserBomList.vue` â†’ `name: 'UserBomList'` |
## å¯¼å…¥è·¯å¾„
```vue
<script lang="ts" setup>
// âœ… æ­£ç¡®ï¼šä½¿ç”¨ # åˆ«å
import { getUser } from '#/api/system/user';
import DictTag from '#/components/dict-tag/dict-tag.vue';
import { useVbenForm } from '#/adapter/form';
// âœ… æ­£ç¡®ï¼šç›¸å¯¹è·¯å¾„导入同目录
import Form from './modules/form.vue';
import { useGridColumns } from './data';
</script>
```
## æ³¨æ„äº‹é¡¹
1. **必须使用 `defineOptions` å®šä¹‰ç»„件名**
2. **统一使用 `<script setup lang="ts">` è¯­æ³•**
3. **Props ä½¿ç”¨ `withDefaults` + `defineProps<T>()`**
4. **Emits ä½¿ç”¨ `defineEmits<T>()`**
5. **样式优先使用 Tailwind CSS**
6. **组件样式必须 `scoped`**
.claude/settings.local.json
@@ -9,7 +9,18 @@
      "PowerShell(Get-ChildItem -Path \"D:\\\\工作\\\\芯导公司\\\\项目\\\\进销存\\\\mom-pro2-before\\\\src\\\\views\\\\mes\" -Directory -Recurse | Where-Object { $_.GetDirectories\\(\\).Count -gt 0 -or $_.Parent.Name -in @\\('mes','md','pro','process-design','cal','dv','qc','wm','tm','pd'\\) } | Select-Object -ExpandProperty FullName | ForEach-Object { $_.Substring\\(66\\) })",
      "PowerShell(cd \"D:\\\\工作\\\\芯导公司\\\\项目\\\\进销存\\\\mom-pro2-before\"; Get-ChildItem -Path \"src/views/mes\" -Directory -Recurse | Select-Object -ExpandProperty FullName | ForEach-Object { $_.Replace\\('D:\\\\工作\\\\芯导公司\\\\项目\\\\进销存\\\\mom-pro2-before\\\\', ''\\) })",
      "PowerShell(cd \"D:\\\\工作\\\\芯导公司\\\\项目\\\\进销存\\\\mom-pro2-before\"; Get-ChildItem -Path \"src/api/mes\" -Recurse -File | Select-Object -ExpandProperty FullName | ForEach-Object { $_.Replace\\('D:\\\\工作\\\\芯导公司\\\\项目\\\\进销存\\\\mom-pro2-before\\\\', ''\\) })",
      "PowerShell(cd \"D:\\\\工作\\\\芯导公司\\\\项目\\\\进销存\\\\mom-pro2-before\"; Get-ChildItem -Path \"src/router\" -Recurse -File | Select-Object -ExpandProperty FullName | ForEach-Object { $_.Replace\\('D:\\\\工作\\\\芯导公司\\\\项目\\\\进销存\\\\mom-pro2-before\\\\', ''\\) })"
      "PowerShell(cd \"D:\\\\工作\\\\芯导公司\\\\项目\\\\进销存\\\\mom-pro2-before\"; Get-ChildItem -Path \"src/router\" -Recurse -File | Select-Object -ExpandProperty FullName | ForEach-Object { $_.Replace\\('D:\\\\工作\\\\芯导公司\\\\项目\\\\进销存\\\\mom-pro2-before\\\\', ''\\) })",
      "Bash(pnpm approve-builds *)",
      "Bash(pnpm list *)",
      "Bash(pnpm search *)",
      "Bash(pnpm add *)",
      "Bash(pnpm exec *)",
      "Bash(pnpm stub *)",
      "Bash(pnpm dev *)",
      "Bash(Get-Content \"C:\\\\Users\\\\云\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\D-----------mom-pro2-after-mom-pro2-before\\\\3ceea64d-d347-46b2-88a2-57009ccd0961\\\\tasks\\\\bak5v4put.output\")",
      "Bash(pnpm rebuild *)",
      "Bash(node lib/script/postinstall.js)",
      "Bash(mvn compile *)"
    ]
  }
}
.claude/skills/api-service-pattern.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,213 @@
---
name: api-service-pattern
description: API æŽ¥å£å®šä¹‰æ¨¡å¼ï¼ŒåŒ…括类型定义、接口函数、分页查询、命名空间规范。
origin: ECC
---
# API æŽ¥å£å®šä¹‰æ¨¡å¼
项目 API æŽ¥å£å¼€å‘规范,使用 namespace ç»„织类型和函数。
## When to Use
- åˆ›å»ºæ–°çš„ API æŽ¥å£æ–‡ä»¶
- å®šä¹‰æŽ¥å£è¯·æ±‚/响应类型
- ç¼–写 CRUD æŽ¥å£å‡½æ•°
- å¤„理分页查询
## æ–‡ä»¶ç»“æž„
```
src/api/
└── mes/
    â””── pro/
        â””── workorder/
            â”œâ”€â”€ index.ts          # å·¥å•接口
            â””── bom/
                â””── index.ts      # å·¥å• BOM æŽ¥å£
```
## æŽ¥å£å®šä¹‰æ¨¡æ¿
```typescript
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace WorkOrderApi {
  /** å·¥å•信息 */
  export interface WorkOrder {
    id?: number;                    // ç¼–号
    code?: string;                  // ç¼–码
    name?: string;                  // åç§°
    status?: number;                // çŠ¶æ€
    createTime?: number;            // åˆ›å»ºæ—¶é—´
  }
  /** åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    code?: string;
    name?: string;
    status?: number;
  }
}
/** æŸ¥è¯¢å·¥å•分页 */
export function getWorkOrderPage(params: WorkOrderApi.PageParams) {
  return requestClient.get<PageResult<WorkOrderApi.WorkOrder>>(
    '/mes/pro/work-order/page',
    { params },
  );
}
/** æŸ¥è¯¢å·¥å•详情 */
export function getWorkOrder(id: number) {
  return requestClient.get<WorkOrderApi.WorkOrder>(
    `/mes/pro/work-order/get?id=${id}`,
  );
}
/** æ–°å¢žå·¥å• */
export function createWorkOrder(data: WorkOrderApi.WorkOrder) {
  return requestClient.post<number>('/mes/pro/work-order/create', data);
}
/** ä¿®æ”¹å·¥å• */
export function updateWorkOrder(data: WorkOrderApi.WorkOrder) {
  return requestClient.put('/mes/pro/work-order/update', data);
}
/** åˆ é™¤å·¥å• */
export function deleteWorkOrder(id: number) {
  return requestClient.delete(`/mes/pro/work-order/delete?id=${id}`);
}
/** å¯¼å‡ºå·¥å• */
export function exportWorkOrder(params: any) {
  return requestClient.download('/mes/pro/work-order/export-excel', { params });
}
```
## ç±»åž‹å®šä¹‰è§„范
### å®žä½“接口
```typescript
export namespace ItemApi {
  /** ç‰©æ–™ä¿¡æ¯ */
  export interface Item {
    id?: number;                    // ç¼–号(可选,新增时无)
    code: string;                   // ç¼–码(必填)
    name: string;                   // åç§°ï¼ˆå¿…填)
    specification?: string;         // è§„格型号
    unitMeasureId?: number;         // å•位编号
    unitMeasureName?: string;       // å•位名称(冗余字段)
    status?: number;                // çŠ¶æ€
    remark?: string;                // å¤‡æ³¨
    createTime?: number;            // åˆ›å»ºæ—¶é—´
  }
}
```
### åˆ†é¡µå‚æ•°
```typescript
export namespace ItemApi {
  /** åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    code?: string;                  // ç¼–码(模糊)
    name?: string;                  // åç§°ï¼ˆæ¨¡ç³Šï¼‰
    status?: number;                // çŠ¶æ€
    categoryId?: number;            // åˆ†ç±»ç¼–号
    createTime?: number[];          // åˆ›å»ºæ—¶é—´èŒƒå›´
  }
}
```
### åˆ—表接口
```typescript
export namespace ProcessApi {
  /** å·¥åºç²¾ç®€ä¿¡æ¯ï¼ˆä¸‹æ‹‰é€‰æ‹©ç”¨ï¼‰ */
  export interface ProcessSimple {
    id: number;
    code: string;
    name: string;
  }
}
/** æŸ¥è¯¢å·¥åºåˆ—表 */
export function getProcessList(params?: ProcessApi.ListParams) {
  return requestClient.get<ProcessApi.Process[]>(
    '/mes/pro/process/list',
    { params },
  );
}
/** æŸ¥è¯¢å·¥åºç²¾ç®€åˆ—表 */
export function getProcessSimpleList() {
  return requestClient.get<ProcessApi.ProcessSimple[]>(
    '/mes/pro/process/simple-list',
  );
}
```
## è¯·æ±‚方法映射
| æ“ä½œ | æ–¹æ³• | URL æ ¼å¼ | è¿”回类型 |
|-----|------|---------|---------|
| åˆ†é¡µæŸ¥è¯¢ | GET | `/module/page` | `PageResult<T>` |
| åˆ—表查询 | GET | `/module/list` | `T[]` |
| ç²¾ç®€åˆ—表 | GET | `/module/simple-list` | `SimpleT[]` |
| è¯¦æƒ…查询 | GET | `/module/get?id=` | `T` |
| æ–°å¢ž | POST | `/module/create` | `number` |
| ä¿®æ”¹ | PUT | `/module/update` | `void` |
| åˆ é™¤ | DELETE | `/module/delete?id=` | `void` |
| å¯¼å‡º | GET | `/module/export-excel` | `Blob` |
## å­æ¨¡å—接口
子模块接口放在父模块目录下:
```
src/api/mes/pro/workorder/
├── index.ts          # å·¥å•接口
└── bom/
    â””── index.ts      # å·¥å• BOM æŽ¥å£
```
```typescript
// bom/index.ts
export namespace WorkOrderBomApi {
  export interface WorkOrderBom {
    id?: number;
    workOrderId?: number;           // å…³è”父表
    itemId?: number;
    itemName?: string;
    quantity?: number;
  }
  export interface PageParams extends PageParam {
    workOrderId?: number;           // å¿…须有父表 ID
  }
}
```
## å¯¼å…¥ä½¿ç”¨
```typescript
// å¯¼å…¥ç±»åž‹
import type { WorkOrderApi } from '#/api/mes/pro/workorder';
import type { WorkOrderBomApi } from '#/api/mes/pro/workorder/bom';
// å¯¼å…¥å‡½æ•°
import { getWorkOrderPage, createWorkOrder } from '#/api/mes/pro/workorder';
```
## æ³¨æ„äº‹é¡¹
1. **使用 namespace ç»„织类型**:避免类型命名冲突
2. **实体字段全部可选**:适应新增/编辑/查询场景
3. **分页参数继承 PageParam**:包含 `pageNo`、`pageSize`
4. **返回类型明确指定**:`requestClient.get<T>`
5. **路径参数使用模板字符串**:`/module/get?id=${id}`
.claude/skills/vben-table-form.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,421 @@
---
name: vben-table-form
description: Vue Vben Admin è¡¨æ ¼å’Œè¡¨å•开发模式,包括 useVbenVxeGrid、useVbenModal、useVbenForm çš„使用。
origin: ECC
---
# Vue Vben Admin è¡¨æ ¼è¡¨å•模式
项目核心组件的使用指南:表格(useVbenVxeGrid)、表单弹窗(useVbenModal)、表单(useVbenForm)。
## When to Use
- å¼€å‘列表页面
- å¼€å‘表单弹窗
- é…ç½®è¡¨æ ¼åˆ—和搜索条件
- å¤„理增删改查操作
## è¡¨æ ¼ç»„ä»¶ (useVbenVxeGrid)
### åŸºç¡€ç”¨æ³•
```vue
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { UserApi } from '#/api/system/user';
import { useVbenVxeGrid, TableAction, ACTION_ICON } from '#/adapter/vxe-table';
import { getUserPage } from '#/api/system/user';
import { getDictOptions } from '@vben/hooks';
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: [
      {
        fieldName: 'username',
        label: '用户名',
        component: 'Input',
      },
      {
        fieldName: 'status',
        label: '状态',
        component: 'Select',
        componentProps: {
          options: getDictOptions('common_status', 'number'),
        },
      },
    ],
  },
  gridOptions: {
    columns: [
      { type: 'checkbox', width: 50 },
      { field: 'username', title: '用户名', width: 120 },
      { field: 'nickname', title: '昵称', minWidth: 150 },
      {
        field: 'status',
        title: '状态',
        width: 100,
        cellRender: {
          name: 'CellDict',
          props: { type: 'common_status' },
        },
      },
      { field: 'createTime', title: '创建时间', width: 180, formatter: 'formatDateTime' },
      { title: '操作', width: 180, fixed: 'right', slots: { default: 'actions' } },
    ],
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getUserPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<UserApi.User>,
});
// åˆ·æ–°è¡¨æ ¼
function handleRefresh() {
  gridApi.query();
}
</script>
<template>
  <Grid table-title="用户列表">
    <template #toolbar-tools>
      <TableAction
        :actions="[
          {
            label: '新增',
            type: 'primary',
            icon: ACTION_ICON.ADD,
            onClick: handleCreate,
          },
        ]"
      />
    </template>
    <template #actions="{ row }">
      <TableAction
        :actions="[
          {
            label: '编辑',
            type: 'link',
            icon: ACTION_ICON.EDIT,
            onClick: handleEdit.bind(null, row),
          },
          {
            label: '删除',
            type: 'link',
            danger: true,
            icon: ACTION_ICON.DELETE,
            popConfirm: {
              title: `确认删除【${row.username}】吗?`,
              confirm: handleDelete.bind(null, row),
            },
          },
        ]"
      />
    </template>
  </Grid>
</template>
```
### è¡¨æ ¼åˆ—渲染器
| æ¸²æŸ“器 | ç”¨é€” | ç¤ºä¾‹ |
|-------|------|------|
| `CellDict` | å­—典标签 | `cellRender: { name: 'CellDict', props: { type: 'common_status' } }` |
| `CellImage` | å•图 | `cellRender: { name: 'CellImage' }` |
| `CellImages` | å¤šå›¾ | `cellRender: { name: 'CellImages' }` |
| `CellLink` | é“¾æŽ¥ | `cellRender: { name: 'CellLink', props: { text: '查看' } }` |
| `CellSwitch` | å¼€å…³ | `cellRender: { name: 'CellSwitch', attrs: { beforeChange } }` |
### è¡¨æ ¼äº‹ä»¶
```typescript
const [Grid, gridApi] = useVbenVxeGrid({
  gridEvents: {
    checkboxAll: ({ records }) => {
      checkedIds.value = records.map(item => item.id!);
    },
    checkboxChange: ({ records }) => {
      checkedIds.value = records.map(item => item.id!);
    },
  },
});
```
## è¡¨å•弹窗 (useVbenModal)
### åˆ—表页定义
```vue
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
// æ–°å¢ž
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
// ç¼–辑
function handleEdit(row: UserApi.User) {
  formModalApi.setData({ formType: 'update', id: row.id }).open();
}
</script>
<template>
  <FormModal @success="handleRefresh" />
</template>
```
### è¡¨å•组件
```vue
<script lang="ts" setup>
import type { UserApi } from '#/api/system/user';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { createUser, updateUser, getUser } from '#/api/system/user';
import { $t } from '#/locales';
const emit = defineEmits(['success']);
const formData = ref<UserApi.User>();
const formType = ref<'create' | 'update'>('create');
const getTitle = computed(() => {
  return formType.value === 'update'
    ? $t('ui.actionTitle.edit', ['用户'])
    : $t('ui.actionTitle.create', ['用户']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: [
    {
      fieldName: 'username',
      label: '用户名',
      component: 'Input',
      rules: 'required',
    },
    {
      fieldName: 'nickname',
      label: '昵称',
      component: 'Input',
      rules: 'required',
    },
    {
      fieldName: 'email',
      label: '邮箱',
      component: 'Input',
      rules: 'email',
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions('common_status', 'number'),
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
    },
  ],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) return;
    modalApi.lock();
    const data = await formApi.getValues();
    try {
      if (formData.value?.id) {
        await updateUser({ ...data, id: formData.value.id });
      } else {
        await createUser(data);
      }
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    const data = modalApi.getData<{ formType: string; id?: number }>();
    formType.value = data.formType;
    if (data.id) {
      modalApi.lock();
      try {
        formData.value = await getUser(data.id);
        await formApi.setValues(formData.value);
      } finally {
        modalApi.unlock();
      }
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-2/5">
    <Form class="mx-4" />
  </Modal>
</template>
```
## è¡¨å• Schema é…ç½®
### å¸¸ç”¨ç»„ä»¶
```typescript
const schema: VbenFormSchema[] = [
  // è¾“入框
  {
    fieldName: 'name',
    label: '名称',
    component: 'Input',
    componentProps: {
      placeholder: '请输入名称',
    },
    rules: 'required',
  },
  // æ•°å­—输入
  {
    fieldName: 'quantity',
    label: '数量',
    component: 'InputNumber',
    componentProps: {
      class: '!w-full',
      min: 0,
      precision: 2,
    },
  },
  // ä¸‹æ‹‰é€‰æ‹©
  {
    fieldName: 'status',
    label: '状态',
    component: 'Select',
    componentProps: {
      options: getDictOptions('common_status', 'number'),
    },
  },
  // æ—¥æœŸé€‰æ‹©
  {
    fieldName: 'date',
    label: '日期',
    component: 'DatePicker',
    componentProps: {
      class: '!w-full',
      format: 'YYYY-MM-DD',
      valueFormat: 'x',
    },
  },
  // éšè—å­—段
  {
    fieldName: 'id',
    component: 'Input',
    dependencies: {
      triggerFields: [''],
      show: () => false,
    },
  },
  // æ¡ä»¶æ˜¾ç¤º
  {
    fieldName: 'clientId',
    label: '客户',
    component: markRaw(CrmCustomerSelect),
    dependencies: {
      triggerFields: ['orderSourceType'],
      show: (values) => values.orderSourceType === 'ORDER',
    },
  },
];
```
## TableAction æ“ä½œæŒ‰é’®
```vue
<TableAction
  :actions="[
    {
      label: '编辑',
      type: 'link',
      icon: ACTION_ICON.EDIT,
      auth: ['system:user:update'],
      onClick: handleEdit,
    },
    {
      label: '删除',
      type: 'link',
      danger: true,
      icon: ACTION_ICON.DELETE,
      auth: ['system:user:delete'],
      popConfirm: {
        title: '确认删除吗?',
        confirm: handleDelete,
      },
    },
  ]"
  :drop-down-actions="[
    {
      label: '分配角色',
      auth: ['system:user:assign-role'],
      onClick: handleAssignRole,
    },
  ]"
/>
```
## æ³¨æ„äº‹é¡¹
1. **表格必须有 `keyField`**:`rowConfig: { keyField: 'id' }`
2. **弹窗必须 `destroyOnClose: true`**:防止数据残留
3. **表单验证使用 `rules` å±žæ€§**:支持 `required`、`email`、`selectRequired` ç­‰
4. **权限控制使用 `auth` å±žæ€§**:`auth: ['system:user:update']`
.claude/skills/vue-component-patterns.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,170 @@
---
name: vue-component-patterns
description: Vue 3 ç»„件开发模式,包括 Composition API、Props/Emits å®šä¹‰ã€ç»„件命名规范。适用于开发 Vue ç»„件时。
origin: ECC
---
# Vue 3 ç»„件开发模式
基于 Vue Vben Admin é¡¹ç›®çš„ Vue 3 ç»„件开发规范。
## When to Use
- å¼€å‘æ–°çš„ Vue ç»„ä»¶
- å®šä¹‰ç»„ä»¶ Props å’Œ Emits
- ä½¿ç”¨ Composition API
- ç»„件状态管理和生命周期
## How It Works
项目使用 Vue 3.5 + TypeScript + `<script setup>` è¯­æ³•。
## ç»„件定义规范
### åŸºç¡€ç»“æž„
```vue
<script lang="ts" setup>
import type { PropsType } from './types';
import { ref, computed } from 'vue';
// 1. ç»„件命名(必须)
defineOptions({ name: 'ComponentName' });
// 2. Props å®šä¹‰
interface Props {
  userId: number;
  userName?: string;
}
const props = withDefaults(defineProps<Props>(), {
  userName: '',
});
// 3. Emits å®šä¹‰
interface Emits {
  (e: 'update', value: string): void;
  (e: 'delete', id: number): void;
}
const emit = defineEmits<Emits>();
// 4. å“åº”式状态
const loading = ref(false);
// 5. è®¡ç®—属性
const displayName = computed(() => props.userName || '未命名');
// 6. æ–¹æ³•
function handleSubmit() {
  emit('update', displayName.value);
}
</script>
<template>
  <div class="component-name">
    <!-- æ¨¡æ¿å†…容 -->
  </div>
</template>
<style lang="scss" scoped>
.component-name {
  /* æ ·å¼ */
}
</style>
```
### TypeScript è§„范
```typescript
// âœ… æ­£ç¡®ï¼šä½¿ç”¨ import type å¯¼å…¥ç±»åž‹
import type { UserInfo } from '#/api/system/user';
// âœ… æ­£ç¡®ï¼šå®šä¹‰å…·ä½“接口
interface Props {
  userId: number;
  userName?: string;
}
// âŒ é”™è¯¯ï¼šä½¿ç”¨ any
const data: any = {};
// âœ… æ­£ç¡®ï¼šä½¿ç”¨ unknown + ç±»åž‹å®ˆå«
function getErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }
  return '未知错误';
}
```
## é¡µé¢æ–‡ä»¶ç»“æž„
标准 CRUD é¡µé¢çš„æ–‡ä»¶ç»“构:
```
views/
└── system/
    â””── user/
        â”œâ”€â”€ index.vue        # åˆ—表页
        â”œâ”€â”€ data.ts          # æ•°æ®å®šä¹‰ï¼ˆè¡¨æ ¼åˆ—、表单 Schema)
        â””── modules/
            â”œâ”€â”€ form.vue     # è¡¨å•弹窗
            â””── detail.vue   # è¯¦æƒ…弹窗(可选)
```
### å¯¼å…¥è·¯å¾„层级
| æ–‡ä»¶ä½ç½® | å¯¼å…¥å±‚级 |
|---------|---------|
| `index.vue` | 4 å±‚ `../..` |
| `modules/form.vue` | 5 å±‚ `../../..` |
```typescript
// index.vue ä¸­çš„导入
import { useFormSchema } from '../data';
// modules/form.vue ä¸­çš„导入
import { useFormSchema } from '../../data';
```
## ç»„件命名规范
| ç±»åž‹ | å‘½åè§„则 | ç¤ºä¾‹ |
|-----|---------|-----|
| é¡µé¢ç»„ä»¶ | ä¸Žç›®å½•名一致 | `UserList.vue` |
| ä¸šåŠ¡ç»„ä»¶ | åŠŸèƒ½å + ç±»åž‹ | `UserSelect.vue` |
| åŸºç¡€ç»„ä»¶ | åŠŸèƒ½å | `Button.vue` |
| å¼¹çª—组件 | åŠŸèƒ½ + Form/Detail | `UserForm.vue` |
## æ ·å¼è§„范
```vue
<template>
  <!-- ä¼˜å…ˆä½¿ç”¨ Tailwind CSS -->
  <div class="flex items-center p-4 bg-white rounded-lg">
    <span class="text-base font-medium text-gray-900">标题</span>
  </div>
</template>
<style lang="scss" scoped>
/* ç»„件样式使用 scoped */
.component-name {
  /* é¢œè‰²ä½¿ç”¨ CSS å˜é‡ */
  background-color: var(--component-background);
  /* è¦†ç›–组件库样式使用 :deep */
  :deep(.ant-table) {
    border-radius: 8px;
  }
}
</style>
```
## æ³¨æ„äº‹é¡¹
1. **必须使用 `defineOptions` å®šä¹‰ç»„件名**
2. **禁止使用 `any` ç±»åž‹**
3. **Props ä½¿ç”¨ `withDefaults` + `defineProps<T>()`**
4. **路径别名 `#/*` æŒ‡å‘ `./src/*`**
CLAUDE.md
@@ -82,9 +82,9 @@
## å¯ç”¨æŠ€èƒ½
- `vue3-development` - Vue 3 å¼€å‘规范
- `antd-vue-components` - Ant Design Vue ç»„件模式
- `frontend-code-review` - å‰ç«¯ä»£ç å®¡æŸ¥æŒ‡å—
- `vue-component-patterns` - Vue 3 ç»„件开发模式(Composition API、Props/Emits)
- `vben-table-form` - è¡¨æ ¼è¡¨å•组件使用(useVbenVxeGrid、useVbenModal、useVbenForm)
- `api-service-pattern` - API æŽ¥å£å®šä¹‰æ¨¡å¼ï¼ˆnamespace、类型定义、CRUD å‡½æ•°ï¼‰
## æ³¨æ„äº‹é¡¹
internal/node-utils/package.json
@@ -38,5 +38,8 @@
    "ora": "catalog:",
    "pkg-types": "catalog:",
    "rimraf": "catalog:"
  },
  "devDependencies": {
    "unrun": "^0.3.1"
  }
}
package.json
@@ -84,5 +84,6 @@
    "tsx": "^4.22.4",
    "typescript": "catalog:",
    "vite": "catalog:"
  }
}
  },
  "packageManager": "pnpm@11.0.9+sha512.34ce82e6780233cf9cad8685029a8f81d2e06196c5a9bad98879f7424940c6817c4e4524fb7d38b8553ceed48b9758b8ebaf1abd3600c232c4c8cf7366086f38"
}
pnpm-lock.yaml
@@ -695,7 +695,7 @@
        version: 2.7.0
      tsdown:
        specifier: ^0.22.3
        version: 0.22.3(tsx@4.22.4)(typescript@6.0.3)
        version: 0.22.3(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13))
      tsx:
        specifier: ^4.22.4
        version: 4.22.4
@@ -871,6 +871,10 @@
      rimraf:
        specifier: 'catalog:'
        version: 6.1.3
    devDependencies:
      unrun:
        specifier: ^0.3.1
        version: 0.3.1(synckit@0.11.13)
  internal/tailwind-config:
    dependencies:
@@ -7315,9 +7319,6 @@
  lodash.snakecase@4.1.1:
    resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
  lodash.sortby@4.7.0:
    resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
  lodash.startcase@4.4.0:
    resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==}
@@ -8635,10 +8636,9 @@
    resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
    engines: {node: '>= 12'}
  source-map@0.8.0-beta.0:
    resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
    engines: {node: '>= 8'}
    deprecated: The work that was done in this beta branch won't be included in future versions
  source-map@0.8.0:
    resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==}
    engines: {node: '>= 12'}
  spawndamnit@3.0.1:
    resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==}
@@ -9026,9 +9026,6 @@
  tr46@0.0.3:
    resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
  tr46@1.0.1:
    resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
  tree-kill@1.2.2:
    resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
    hasBin: true
@@ -9304,6 +9301,16 @@
  unplugin@3.0.0:
    resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
    engines: {node: ^20.19.0 || >=22.12.0}
  unrun@0.3.1:
    resolution: {integrity: sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==}
    engines: {node: ^22.13.0 || >=24.0.0}
    hasBin: true
    peerDependencies:
      synckit: ^0.11.11
    peerDependenciesMeta:
      synckit:
        optional: true
  unstorage@1.17.5:
    resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==}
@@ -9633,9 +9640,6 @@
  webidl-conversions@3.0.0:
    resolution: {integrity: sha512-GOK494qXZl0XBbr/YPRW2BL6oLfe+d0KDSWndKH7NdxJwJ0fCdhQG46Q2D7BmIS6cN/FJgtju8srRt/jlG9abA==}
  webidl-conversions@4.0.2:
    resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
  webpack-virtual-modules@0.6.2:
    resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
@@ -9654,9 +9658,6 @@
  whatwg-url@5.0.0:
    resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
  whatwg-url@7.0.0:
    resolution: {integrity: sha512-37GeVSIJ3kn1JgKyjiYNmSLP1yzbpb29jdmwBSgkD9h40/hyrR/OifpVUndji3tmwGgD8qpw7iQu3RSbCrBpsQ==}
  which-boxed-primitive@1.1.1:
    resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
@@ -15596,8 +15597,6 @@
  lodash.snakecase@4.1.1: {}
  lodash.sortby@4.7.0: {}
  lodash.startcase@4.4.0: {}
  lodash.truncate@4.4.2: {}
@@ -17085,9 +17084,7 @@
  source-map@0.7.6: {}
  source-map@0.8.0-beta.0:
    dependencies:
      whatwg-url: 7.0.0
  source-map@0.8.0: {}
  spawndamnit@3.0.1:
    dependencies:
@@ -17551,10 +17548,6 @@
  tr46@0.0.3: {}
  tr46@1.0.1:
    dependencies:
      punycode: 2.3.1
  tree-kill@1.2.2: {}
  trim-newlines@5.0.0: {}
@@ -17576,7 +17569,7 @@
      minimist: 1.2.8
      strip-bom: 3.0.0
  tsdown@0.22.3(tsx@4.22.4)(typescript@6.0.3):
  tsdown@0.22.3(tsx@4.22.4)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13)):
    dependencies:
      ansis: 4.3.1
      cac: 7.0.0
@@ -17596,6 +17589,7 @@
    optionalDependencies:
      tsx: 4.22.4
      typescript: 6.0.3
      unrun: 0.3.1(synckit@0.11.13)
    transitivePeerDependencies:
      - '@ts-macro/tsc'
      - '@typescript/native-preview'
@@ -17829,6 +17823,12 @@
      '@jridgewell/remapping': 2.3.5
      picomatch: 4.0.4
      webpack-virtual-modules: 0.6.2
  unrun@0.3.1(synckit@0.11.13):
    dependencies:
      rolldown: 1.1.2
    optionalDependencies:
      synckit: 0.11.13
  unstorage@1.17.5(db0@0.3.4)(ioredis@5.11.1):
    dependencies:
@@ -18148,8 +18148,6 @@
  webidl-conversions@3.0.0: {}
  webidl-conversions@4.0.2: {}
  webpack-virtual-modules@0.6.2: {}
  webrtc-adapter@9.0.5:
@@ -18166,12 +18164,6 @@
    dependencies:
      tr46: 0.0.3
      webidl-conversions: 3.0.0
  whatwg-url@7.0.0:
    dependencies:
      lodash.sortby: 4.7.0
      tr46: 1.0.1
      webidl-conversions: 4.0.2
  which-boxed-primitive@1.1.1:
    dependencies:
@@ -18254,7 +18246,7 @@
      glob: 11.0.1
      pretty-bytes: 5.6.0
      rollup: 4.62.2
      source-map: 0.8.0-beta.0
      source-map: 0.8.0
      stringify-object: 3.3.0
      strip-comments: 2.0.1
      tempy: 0.6.0
src/api/mes/pro/feedback/index.ts
@@ -114,6 +114,61 @@
  return requestClient.put<boolean>(`/mes/pro/feedback/approve?id=${id}`);
}
/** å¿«é€ŸæŠ¥å·¥è¯·æ±‚ */
export interface QuickCreateReq {
  taskId: number;
  feedbackQuantity: number;
  qualifiedQuantity?: number;
  unqualifiedQuantity?: number;
  remark?: string;
}
/** å¿«é€ŸæŠ¥å·¥å“åº” */
export interface QuickCreateResp {
  feedbackId?: number;
  taskStatus?: number;
  workOrderStatus?: number;
}
/** æ‰¹é‡æŠ¥å·¥è¯·æ±‚ */
export interface BatchCreateReq {
  taskIds: number[];
  feedbackQuantity: number;
  qualifiedQuantity?: number;
  unqualifiedQuantity?: number;
  remark?: string;
}
/** æ‰¹é‡æŠ¥å·¥å“åº” */
export interface BatchCreateResp {
  successCount?: number;
  failCount?: number;
  results?: BatchResult[];
}
export interface BatchResult {
  taskId?: number;
  success?: boolean;
  feedbackId?: number;
  errorMsg?: string;
}
/** å¿«é€ŸæŠ¥å·¥ï¼ˆåˆ›å»ºå¹¶æäº¤ï¼‰ */
export function quickCreateFeedback(data: QuickCreateReq) {
  return requestClient.post<QuickCreateResp>(
    '/mes/pro/feedback/quick-create',
    data,
  );
}
/** æ‰¹é‡æŠ¥å·¥ */
export function batchCreateFeedback(data: BatchCreateReq) {
  return requestClient.post<BatchCreateResp>(
    '/mes/pro/feedback/batch-create',
    data,
  );
}
/** æŒ‰ä»»åŠ¡æŸ¥è¯¢æŠ¥å·¥åŽ†å² */
export function getFeedbackListByTask(taskId: number) {
  return requestClient.get<MesProFeedbackApi.Feedback[]>(
src/api/mes/pro/task/index.ts
@@ -114,6 +114,48 @@
  return requestClient.get<any[]>('/mes/pro/task/gantt-list', { params });
}
/** ä¸€é”®æŽ’产预览请求 */
export interface AutoGenerateReq {
  workOrderId: number;
  routeId?: number;
  autoAssignWorkstation?: boolean;
  autoCalculateTime?: boolean;
}
/** ä¸€é”®æŽ’产预览响应 */
export interface AutoGeneratePreview {
  workOrderId?: number;
  routeId?: number;
  routeName?: string;
  taskList?: TaskPreview[];
}
export interface TaskPreview {
  processId?: number;
  processName?: string;
  sort?: number;
  suggestWorkstationId?: number;
  suggestWorkstationName?: string;
  quantity?: number;
  keyFlag?: boolean;
}
/** ä¸€é”®æŽ’产预览 */
export function previewAutoGenerate(data: AutoGenerateReq) {
  return requestClient.post<AutoGeneratePreview>(
    '/mes/pro/task/auto-generate/preview',
    data,
  );
}
/** ä¸€é”®æŽ’产执行 */
export function autoGenerateTasks(data: AutoGenerateReq) {
  return requestClient.post<{ taskIds: number[] }>(
    '/mes/pro/task/auto-generate',
    data,
  );
}
/** æŸ¥è¯¢å¾…报工台账(分页) */
export function getPendingFeedbackPage(
  params: MesProTaskApi.PendingFeedbackPageParams,
src/api/mes/pro/workorder/index.ts
@@ -99,3 +99,50 @@
export function confirmWorkOrder(id: number) {
  return requestClient.put(`/mes/pro/work-order/confirm?id=${id}`);
}
/** å·¥å•进度详情 */
export interface WorkOrderProgress {
  workOrderId?: number;
  workOrderCode?: string;
  quantity?: number;
  quantityScheduled?: number;
  quantityProduced?: number;
  progressPercent?: number;
  processList?: ProcessProgress[];
}
export interface ProcessProgress {
  processId?: number;
  processCode?: string;
  processName?: string;
  keyFlag?: boolean;
  plannedQuantity?: number;
  producedQuantity?: number;
  progressPercent?: number;
  status?: number;
  taskList?: TaskInfo[];
}
export interface TaskInfo {
  taskId?: number;
  taskCode?: string;
  workstationName?: string;
  quantity?: number;
  producedQuantity?: number;
  progressPercent?: number;
  status?: number;
}
/** èŽ·å–å·¥å•è¿›åº¦è¯¦æƒ… */
export function getWorkOrderProgress(id: number) {
  return requestClient.get<WorkOrderProgress>(
    `/mes/pro/work-order/progress/${id}`,
  );
}
/** æ£€æŸ¥å¹¶è‡ªåŠ¨å®Œæˆå·¥å• */
export function checkAndAutoFinishWorkOrder(id: number) {
  return requestClient.post<boolean>(
    `/mes/pro/work-order/check-auto-finish/${id}`,
  );
}
src/api/mes/pro/workorder/process/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
import type { MesProWorkOrderProcessApi } from './index';
import { requestClient } from '#/api/request';
export namespace MesProWorkOrderProcessApi {
  /** MES ç”Ÿäº§å·¥å•工序 */
  export interface WorkOrderProcess {
    id?: number; // ç¼–号
    workOrderId?: number; // ç”Ÿäº§å·¥å•编号
    processId?: number; // å·¥åºç¼–号
    processCode?: string; // å·¥åºç¼–码
    processName?: string; // å·¥åºåç§°
    sort?: number; // åºå·
    nextProcessId?: number; // ä¸‹ä¸€é“工序编号
    nextProcessName?: string; // ä¸‹ä¸€é“工序名称
    linkType?: number; // ä¸Žä¸‹ä¸€é“工序关系
    prepareTime?: number; // å‡†å¤‡æ—¶é—´ï¼ˆåˆ†é’Ÿï¼‰
    waitTime?: number; // ç­‰å¾…时间(分钟)
    colorCode?: string; // ç”˜ç‰¹å›¾æ˜¾ç¤ºé¢œè‰²
    keyFlag?: boolean; // æ˜¯å¦å…³é”®å·¥åº
    checkFlag?: boolean; // æ˜¯å¦è´¨æ£€å·¥åº
    backflushFlag?: boolean; // æ˜¯å¦å€’冲
    outputItemId?: number; // äº§å‡ºäº§å“ç‰©æ–™ç¼–号
    outputItemCode?: string; // äº§å‡ºäº§å“ç‰©æ–™ç¼–码
    outputItemName?: string; // äº§å‡ºäº§å“ç‰©æ–™åç§°
    remark?: string; // å¤‡æ³¨
  }
}
/** æŸ¥è¯¢å·¥å•工序列表 */
export function getWorkOrderProcessListByWorkOrderId(workOrderId: number) {
  return requestClient.get<MesProWorkOrderProcessApi.WorkOrderProcess[]>(
    `/mes/pro/work-order-process/list-by-work-order-id?workOrderId=${workOrderId}`,
  );
}
/** æ ¹æ®äº§å“èŽ·å–å·¥è‰ºè·¯çº¿å·¥åºåˆ—è¡¨ï¼ˆç”¨äºŽå·¥å•é¢„å¡«ï¼‰ */
export function getProcessListByProductId(productId: number) {
  return requestClient.get<MesProWorkOrderProcessApi.WorkOrderProcess[]>(
    `/mes/pro/work-order-process/list-by-product-id?productId=${productId}`,
  );
}
/** æ‰¹é‡ä¿å­˜å·¥å•工序(新增+更新+删除) */
export function batchSaveWorkOrderProcess(data: MesProWorkOrderProcessApi.WorkOrderProcess[]) {
  return requestClient.post('/mes/pro/work-order-process/batch-save', data);
}
/** æŸ¥è¯¢å·¥å•工序详情 */
export function getWorkOrderProcess(id: number) {
  return requestClient.get<MesProWorkOrderProcessApi.WorkOrderProcess>(
    `/mes/pro/work-order-process/get?id=${id}`,
  );
}
/** æ–°å¢žå·¥å•工序 */
export function createWorkOrderProcess(data: MesProWorkOrderProcessApi.WorkOrderProcess) {
  return requestClient.post<number>('/mes/pro/work-order-process/create', data);
}
/** ä¿®æ”¹å·¥å•工序 */
export function updateWorkOrderProcess(data: MesProWorkOrderProcessApi.WorkOrderProcess) {
  return requestClient.put('/mes/pro/work-order-process/update', data);
}
/** åˆ é™¤å·¥å•工序 */
export function deleteWorkOrderProcess(id: number) {
  return requestClient.delete(`/mes/pro/work-order-process/delete?id=${id}`);
}
src/components/file-preview/file-preview-modal.vue
@@ -15,9 +15,7 @@
const VueOfficePptx = defineAsyncComponent(() => import('@vue-office/pptx'));
// @vue-office/excel éœ€è¦é¢å¤–导入 CSS
// @vue-office/excel éœ€è¦é¢å¤–导入 CSS
// @vue-office/excel éœ€è¦é¢å¤–导入 CSS
import '@vue-office/excel-css';
import '@vue-office/excel/lib/v3/style.css';
const props = withDefaults(
  defineProps<{
src/views/mes/pro/feedback/index.vue
@@ -19,12 +19,24 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import QuickFeedbackForm from '../workorder-center/modules/quick-feedback.vue';
import BatchFeedbackForm from './modules/batch-feedback.vue';
const userStore = useUserStore();
const currentUserId = userStore.userInfo?.id; // å½“前登录用户 ID,用于审批人权限判断
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [QuickFeedbackModal, quickFeedbackModalApi] = useVbenModal({
  connectedComponent: QuickFeedbackForm,
  destroyOnClose: true,
});
const [BatchFeedbackModal, batchFeedbackModalApi] = useVbenModal({
  connectedComponent: BatchFeedbackForm,
  destroyOnClose: true,
});
@@ -79,6 +91,16 @@
  downloadFileFromBlobPart({ fileName: '生产报工.xls', source: data });
}
/** å¿«é€ŸæŠ¥å·¥ */
function handleQuickCreate() {
  quickFeedbackModalApi.setData({}).open();
}
/** æ‰¹é‡æŠ¥å·¥ */
function handleBatchCreate() {
  batchFeedbackModalApi.setData({}).open();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: { schema: useGridFormSchema() },
  gridOptions: {
@@ -103,7 +125,10 @@
</script>
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <QuickFeedbackModal @success="handleRefresh" />
    <BatchFeedbackModal @success="handleRefresh" />
    <Grid table-title="生产报工列表">
      <template #toolbar-tools>
        <TableAction
@@ -116,6 +141,19 @@
              onClick: handleCreate,
            },
            {
              label: '快速报工',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-feedback:create'],
              onClick: handleQuickCreate,
            },
            {
              label: '批量报工',
              type: 'primary',
              auth: ['mes:pro-feedback:create'],
              onClick: handleBatchCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
src/views/mes/pro/feedback/modules/batch-feedback.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,147 @@
<script lang="ts" setup>
import type { MesProTaskApi } from '#/api/mes/pro/task';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message, Tag } from 'ant-design-vue';
import { batchCreateFeedback } from '#/api/mes/pro/feedback';
import ProTaskSelectDialog from '#/views/mes/pro/task/components/select-dialog.vue';
defineOptions({ name: 'BatchFeedbackForm' });
interface Emits {
  (e: 'success'): void;
}
const emit = defineEmits<Emits>();
const [Modal, modalApi] = useVbenModal({
  onOpenChange: (open) => {
    if (open) {
      selectedTasks.value = [];
      formData.value = {
        feedbackQuantity: undefined,
        qualifiedQuantity: undefined,
        unqualifiedQuantity: undefined,
        remark: '',
      };
    }
  },
});
const taskDialogRef = ref<InstanceType<typeof ProTaskSelectDialog>>();
const selectedTasks = ref<MesProTaskApi.Task[]>([]);
const formData = ref({
  feedbackQuantity: undefined as number | undefined,
  qualifiedQuantity: undefined as number | undefined,
  unqualifiedQuantity: undefined as number | undefined,
  remark: '',
});
const submitting = ref(false);
function handleOpenTaskDialog() {
  taskDialogRef.value?.open([], { multiple: true });
}
function handleTaskSelected(rows: MesProTaskApi.Task[]) {
  selectedTasks.value = rows;
}
function handleRemoveTask(taskId: number) {
  selectedTasks.value = selectedTasks.value.filter((t) => t.id !== taskId);
}
async function handleSubmit() {
  if (!selectedTasks.value.length) {
    message.warning('请选择任务');
    return;
  }
  if (!formData.value.feedbackQuantity) {
    message.warning('请输入报工数量');
    return;
  }
  submitting.value = true;
  try {
    const result = await batchCreateFeedback({
      taskIds: selectedTasks.value.map((t) => t.id!),
      feedbackQuantity: formData.value.feedbackQuantity,
      qualifiedQuantity: formData.value.qualifiedQuantity,
      unqualifiedQuantity: formData.value.unqualifiedQuantity,
      remark: formData.value.remark || undefined,
    });
    if (result.failCount) {
      message.warning(
        `批量报工完成:成功 ${result.successCount} ä¸ªï¼Œå¤±è´¥ ${result.failCount} ä¸ª`,
      );
    } else {
      message.success(`批量报工成功,共 ${result.successCount} ä¸ª`);
    }
    modalApi.close();
    emit('success');
  } finally {
    submitting.value = false;
  }
}
</script>
<template>
  <Modal title="批量报工" @ok="handleSubmit">
    <a-form layout="vertical">
      <a-form-item label="选择任务" required>
        <div class="mb-2">
          <Button type="dashed" @click="handleOpenTaskDialog">
            {{ selectedTasks.length ? `已选 ${selectedTasks.length} ä¸ªä»»åŠ¡` : '点击选择任务' }}
          </Button>
        </div>
        <div v-if="selectedTasks.length" class="flex flex-wrap gap-1">
          <Tag
            v-for="task in selectedTasks"
            :key="task.id"
            closable
            @close="handleRemoveTask(task.id!)"
          >
            {{ task.code }}
          </Tag>
        </div>
      </a-form-item>
      <a-form-item label="报工数量" required>
        <a-input-number
          v-model:value="formData.feedbackQuantity"
          :min="0.01"
          :precision="2"
          placeholder="请输入报工数量(应用到所有选中任务)"
          class="!w-full"
        />
      </a-form-item>
      <a-form-item label="合格品数量">
        <a-input-number
          v-model:value="formData.qualifiedQuantity"
          :min="0"
          :precision="2"
          placeholder="请输入合格品数量"
          class="!w-full"
        />
      </a-form-item>
      <a-form-item label="不良品数量">
        <a-input-number
          v-model:value="formData.unqualifiedQuantity"
          :min="0"
          :precision="2"
          placeholder="请输入不良品数量"
          class="!w-full"
        />
      </a-form-item>
      <a-form-item label="备注">
        <a-textarea
          v-model:value="formData.remark"
          placeholder="请输入备注"
          :rows="2"
        />
      </a-form-item>
    </a-form>
    <ProTaskSelectDialog ref="taskDialogRef" @selected="handleTaskSelected" />
  </Modal>
</template>
src/views/mes/pro/task/index.vue
@@ -11,21 +11,27 @@
  MesProWorkOrderTypeEnum,
} from '@vben/constants';
import { Button, Card } from 'ant-design-vue';
import { Button, Card, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getGanttTaskList } from '#/api/mes/pro/task';
import { getGanttTaskList, previewAutoGenerate, autoGenerateTasks } from '#/api/mes/pro/task';
import { getWorkOrderPage } from '#/api/mes/pro/workorder';
import { GanttChart } from './components';
import { useGridColumns, useGridFormSchema } from './data';
import ScheduleForm from './modules/schedule-form.vue';
import SchedulePreview from '../workorder-center/modules/schedule-preview.vue';
const router = useRouter();
const ganttTasks = ref<any[]>([]); // ç”˜ç‰¹å›¾é¢„览数据
const [ScheduleModal, scheduleModalApi] = useVbenModal({
  connectedComponent: ScheduleForm,
  destroyOnClose: true,
});
const [SchedulePreviewModal, schedulePreviewModalApi] = useVbenModal({
  connectedComponent: SchedulePreview,
  destroyOnClose: true,
});
@@ -51,6 +57,29 @@
/** æŽ’产 */
function handleSchedule(row: MesProWorkOrderApi.WorkOrder) {
  scheduleModalApi.setData({ formType: 'schedule', id: row.id }).open();
}
/** ä¸€é”®æŽ’产预览 */
async function handleAutoSchedulePreview(row: MesProWorkOrderApi.WorkOrder) {
  try {
    const preview = await previewAutoGenerate({ workOrderId: row.id! });
    schedulePreviewModalApi
      .setData({ workOrder: row, preview })
      .open();
  } catch {
    message.error('获取排产预览失败');
  }
}
/** ä¸€é”®æŽ’产执行 */
async function handleAutoSchedule(row: MesProWorkOrderApi.WorkOrder) {
  try {
    const result = await autoGenerateTasks({ workOrderId: row.id! });
    message.success(`排产成功,生成 ${result.taskIds?.length || 0} ä¸ªä»»åŠ¡`);
    handleRefresh();
  } catch {
    message.error('排产失败');
  }
}
/** æ‰“开甘特图编辑页面 */
@@ -100,7 +129,9 @@
</script>
<template>
  <Page auto-content-height><ScheduleModal @success="handleRefresh" />
  <Page auto-content-height>
    <ScheduleModal @success="handleRefresh" />
    <SchedulePreviewModal @success="handleRefresh" />
    <!-- æŽ’产甘特图预览 -->
    <Card title="排产甘特图" class="mb-4">
@@ -135,6 +166,13 @@
              ifShow: row.status === MesProWorkOrderStatusEnum.CONFIRMED,
              onClick: handleSchedule.bind(null, row),
            },
            {
              label: '一键排产',
              type: 'link',
              auth: ['mes:pro-task:create'],
              ifShow: row.status === MesProWorkOrderStatusEnum.CONFIRMED,
              onClick: handleAutoSchedulePreview.bind(null, row),
            },
          ]"
        />
      </template>
src/views/mes/pro/workorder-center/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,182 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
import { markRaw } from 'vue';
import {
  DICT_TYPE,
  MesProWorkOrderStatusEnum,
  MesProWorkOrderTypeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import { getRangePickerDefaultProps } from '#/utils';
import { MdItemSelect } from '#/views/mes/md/item/components';
/** å·¥å•中心搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '订单编码',
      component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入订单编码' },
    },
    {
      fieldName: 'name',
      label: '订单名称',
      component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入订单名称' },
    },
    {
      fieldName: 'productId',
      label: '产品',
      component: markRaw(MdItemSelect),
      componentProps: { placeholder: '请选择产品' },
    },
    {
      fieldName: 'clientId',
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: { placeholder: '请选择客户' },
    },
    {
      fieldName: 'requestDate',
      label: '需求日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        allowClear: true,
      },
    },
  ];
}
/** å·¥å•中心列表字段 */
export function useGridColumns(): VxeTableGridOptions<MesProWorkOrderApi.WorkOrder>['columns'] {
  return [
    {
      field: 'code',
      title: '订单编码',
      fixed: 'left',
      width: 200,
      treeNode: true,
      slots: { default: 'code' },
    },
    {
      field: 'name',
      title: '订单名称',
      minWidth: 150,
    },
    {
      field: 'productCode',
      title: '产品编码',
      width: 120,
    },
    {
      field: 'productName',
      title: '产品名称',
      minWidth: 120,
    },
    {
      field: 'productSpecification',
      title: '规格型号',
      width: 120,
    },
    {
      field: 'unitMeasureName',
      title: '单位',
      width: 80,
    },
    {
      field: 'quantity',
      title: '生产数量',
      width: 100,
    },
    {
      field: 'quantityScheduled',
      title: '已排产',
      width: 100,
    },
    {
      field: 'quantityProduced',
      title: '已生产',
      width: 100,
    },
    {
      field: 'clientName',
      title: '客户名称',
      width: 120,
    },
    {
      field: 'requestDate',
      title: '需求日期',
      width: 120,
      formatter: 'formatDate',
    },
    {
      field: 'status',
      title: '状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_PRO_WORK_ORDER_STATUS },
      },
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
/** è¿›åº¦ä¸­çš„任务列表字段 */
export function useTaskGridColumns(): VxeTableGridOptions<any>['columns'] {
  return [
    {
      field: 'taskCode',
      title: '任务编码',
      width: 180,
    },
    {
      field: 'workstationName',
      title: '工作站',
      width: 140,
    },
    {
      field: 'quantity',
      title: '排产数量',
      width: 100,
    },
    {
      field: 'producedQuantity',
      title: '已生产',
      width: 100,
    },
    {
      field: 'progressPercent',
      title: '进度',
      width: 120,
      slots: { default: 'taskProgress' },
    },
    {
      field: 'status',
      title: '状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_PRO_TASK_STATUS },
      },
    },
    {
      title: '操作',
      width: 100,
      fixed: 'right',
      slots: { default: 'taskActions' },
    },
  ];
}
src/views/mes/pro/workorder-center/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,313 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProWorkOrderApi, TaskInfo } from '#/api/mes/pro/workorder';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { MesProWorkOrderStatusEnum, MesProWorkOrderTypeEnum } from '@vben/constants';
import { Button, Card, Collapse, Progress, Tag, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  getWorkOrderPage,
  getWorkOrderProgress,
  checkAndAutoFinishWorkOrder,
} from '#/api/mes/pro/workorder';
import { previewAutoGenerate, autoGenerateTasks } from '#/api/mes/pro/task';
import { useGridColumns, useGridFormSchema, useTaskGridColumns } from './data';
import QuickFeedbackForm from './modules/quick-feedback.vue';
import SchedulePreviewModal from './modules/schedule-preview.vue';
defineOptions({ name: 'WorkorderCenter' });
const router = useRouter();
const progressData = ref<MesProWorkOrderApi.WorkOrderProgress | null>(null);
const activeProcessKeys = ref<string[]>([]);
const expandedRowId = ref<number | null>(null);
const [QuickFeedbackModal, quickFeedbackModalApi] = useVbenModal({
  connectedComponent: QuickFeedbackForm,
  destroyOnClose: true,
});
const [SchedulePreviewModal, schedulePreviewModalApi] = useVbenModal({
  connectedComponent: SchedulePreviewModal,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
  // å¦‚果进度详情展开中,刷新进度数据
  if (expandedRowId.value) {
    refreshProgress();
  }
}
async function refreshProgress() {
  if (!expandedRowId.value) return;
  progressData.value = await getWorkOrderProgress(expandedRowId.value);
}
/** å¿«é€ŸæŠ¥å·¥æˆåŠŸåŽåˆ·æ–°è¿›åº¦å¹¶æ£€æŸ¥è‡ªåŠ¨å®Œæˆ */
async function handleFeedbackSuccess() {
  handleRefresh();
  if (expandedRowId.value) {
    try {
      await checkAndAutoFinishWorkOrder(expandedRowId.value);
    } catch {
      // è‡ªåŠ¨å®Œæˆæ£€æŸ¥å¤±è´¥ä¸å½±å“ä¸»æµç¨‹
    }
  }
}
/** æŸ¥çœ‹å·¥å•进度(展开行) */
async function handleToggleProgress(row: MesProWorkOrderApi.WorkOrder) {
  if (expandedRowId.value === row.id) {
    expandedRowId.value = null;
    progressData.value = null;
    return;
  }
  expandedRowId.value = row.id!;
  progressData.value = await getWorkOrderProgress(row.id!);
  // é»˜è®¤å±•开第一个工序
  if (progressData.value?.processList?.length) {
    activeProcessKeys.value = [String(progressData.value.processList[0].processId)];
  }
}
/** å·¥åºè¿›åº¦æ¡é¢œè‰² */
function getProcessStatusColor(status: number): string {
  switch (status) {
    case 2: return '#52c41a';
    case 1: return '#1677ff';
    default: return '#d9d9d9';
  }
}
/** å·¥åºçŠ¶æ€æ–‡å­— */
function getProcessStatusText(status: number): string {
  switch (status) {
    case 2: return '已完成';
    case 1: return '进行中';
    default: return '未开始';
  }
}
/** å¿«é€ŸæŠ¥å·¥ */
function handleQuickFeedback(task: TaskInfo) {
  quickFeedbackModalApi
    .setData({ taskId: task.taskId, taskCode: task.taskCode, taskQuantity: task.quantity, taskProducedQuantity: task.producedQuantity })
    .open();
}
/** ä¸€é”®æŽ’产预览 */
async function handleSchedulePreview(row: MesProWorkOrderApi.WorkOrder) {
  try {
    const preview = await previewAutoGenerate({ workOrderId: row.id! });
    schedulePreviewModalApi
      .setData({ workOrder: row, preview })
      .open();
  } catch {
    message.error('获取排产预览失败');
  }
}
/** æ‰§è¡Œä¸€é”®æŽ’产 */
async function handleScheduleExecute(row: MesProWorkOrderApi.WorkOrder) {
  try {
    const result = await autoGenerateTasks({ workOrderId: row.id! });
    message.success(`排产成功,生成 ${result.taskIds?.length || 0} ä¸ªä»»åŠ¡`);
    handleRefresh();
  } catch {
    message.error('排产失败');
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getWorkOrderPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            status: MesProWorkOrderStatusEnum.CONFIRMED,
            type: MesProWorkOrderTypeEnum.SELF,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<MesProWorkOrderApi.WorkOrder>,
});
</script>
<template>
  <Page auto-content-height>
    <QuickFeedbackModal @success="handleFeedbackSuccess" />
    <SchedulePreviewModal @success="handleRefresh" />
    <Grid table-title="工单中心">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '创建工单',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-work-order:create'],
              onClick: () => router.push({ name: 'MesProWorkorder' }),
            },
          ]"
        />
      </template>
      <template #code="{ row }">
        <Button type="link" @click="handleToggleProgress(row)">
          {{ row.code }}
        </Button>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '排产预览',
              type: 'link',
              auth: ['mes:pro-task:create'],
              ifShow: row.quantityScheduled < row.quantity,
              onClick: handleSchedulePreview.bind(null, row),
            },
            {
              label: '一键排产',
              type: 'link',
              auth: ['mes:pro-task:create'],
              ifShow: row.quantityScheduled < row.quantity,
              popConfirm: {
                title: '确认一键排产?将按照工艺路线自动生成任务。',
                confirm: handleScheduleExecute.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
    <!-- å±•开的工单进度详情 -->
    <Card
      v-if="progressData"
      title="工单进度详情"
      class="mt-4"
      size="small"
    >
      <template #extra>
        <Button type="link" @click="expandedRowId = null; progressData = null">收起</Button>
      </template>
      <!-- æ€»ä½“进度 -->
      <div class="mb-4 flex items-center gap-4">
        <span class="text-gray-500">总进度:</span>
        <Progress
          :percent="Math.round(progressData.progressPercent || 0)"
          :stroke-color="{
            from: '#1677ff',
            to: '#52c41a',
          }"
          class="flex-1"
        />
        <span class="text-sm text-gray-400">
          {{ progressData.quantityProduced }} / {{ progressData.quantity }}
        </span>
      </div>
      <!-- å·¥åºè¿›åº¦æ­¥éª¤æ¡ -->
      <div class="mb-4 flex items-center gap-0">
        <template v-for="(proc, idx) in progressData.processList" :key="proc.processId!">
          <!-- èŠ‚ç‚¹ -->
          <div class="flex flex-col items-center" style="flex: 1">
            <Tag :color="getProcessStatusColor(proc.status!)">
              {{ proc.processName }}
            </Tag>
            <span class="mt-1 text-xs text-gray-400">
              {{ getProcessStatusText(proc.status!) }}
            </span>
            <Progress
              v-if="proc.keyFlag"
              :percent="Math.round(proc.progressPercent || 0)"
              :size="80"
              type="circle"
              :stroke-color="getProcessStatusColor(proc.status!)"
              class="mt-1"
            />
          </div>
          <!-- è¿žçº¿ -->
          <div
            v-if="idx < progressData.processList!.length - 1"
            class="h-0.5 flex-1"
            :style="{
              background:
                proc.status === 2
                  ? '#52c41a'
                  : proc.status === 1
                    ? '#1677ff'
                    : '#d9d9d9',
            }"
          />
        </template>
      </div>
      <!-- ä»»åŠ¡æ˜Žç»† -->
      <Collapse v-model:activeKey="activeProcessKeys">
        <Collapse.Panel
          v-for="proc in progressData.processList"
          :key="String(proc.processId)"
          :header="`${proc.processName}(${proc.producedQuantity || 0} / ${proc.plannedQuantity || proc.producedQuantity || 0})`"
        >
          <vxe-grid
            :key="proc.processId"
            :columns="useTaskGridColumns()"
            :data="proc.taskList || []"
            height="auto"
            size="small"
          >
            <template #taskProgress="{ row: taskRow }">
              <Progress
                :percent="Math.round(taskRow.progressPercent || 0)"
                size="small"
              />
            </template>
            <template #taskActions="{ row: taskRow }">
              <Button
                type="link"
                size="small"
                :disabled="taskRow.progressPercent >= 100"
                @click="handleQuickFeedback(taskRow)"
              >
                æŠ¥å·¥
              </Button>
            </template>
          </vxe-grid>
        </Collapse.Panel>
      </Collapse>
    </Card>
  </Page>
</template>
src/views/mes/pro/workorder-center/modules/quick-feedback.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,158 @@
<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import type { MesProTaskApi } from '#/api/mes/pro/task';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { quickCreateFeedback } from '#/api/mes/pro/feedback';
import ProTaskSelect from '#/views/mes/pro/task/components/select.vue';
defineOptions({ name: 'QuickFeedbackForm' });
interface Props {
  taskId?: number;
  taskCode?: string;
  taskQuantity?: number;
  taskProducedQuantity?: number;
}
const props = withDefaults(defineProps<Props>(), {});
interface Emits {
  (e: 'success'): void;
}
const emit = defineEmits<Emits>();
const [Modal, modalApi] = useVbenModal({
  onOpenChange: (open) => {
    if (open) {
      selectedTaskId.value = props.taskId;
      selectedTask.value = undefined;
      formData.value = {
        feedbackQuantity: undefined,
        qualifiedQuantity: undefined,
        unqualifiedQuantity: undefined,
        remark: '',
      };
    }
  },
});
const formRef = ref<FormInstance>();
const selectedTaskId = ref<number | undefined>(props.taskId);
const selectedTask = ref<MesProTaskApi.Task>();
const formData = ref({
  feedbackQuantity: undefined as number | undefined,
  qualifiedQuantity: undefined as number | undefined,
  unqualifiedQuantity: undefined as number | undefined,
  remark: '',
});
const submitting = ref(false);
const hasPredefinedTask = computed(() => !!props.taskId);
const pendingQuantity = computed(() => {
  if (hasPredefinedTask.value) {
    if (props.taskQuantity == null || props.taskProducedQuantity == null) return 0;
    return Math.max(0, Number(props.taskQuantity) - Number(props.taskProducedQuantity));
  }
  if (!selectedTask.value) return 0;
  const qty = Number(selectedTask.value.quantity || 0);
  const produced = Number(selectedTask.value.producedQuantity || 0);
  return Math.max(0, qty - produced);
});
function handleTaskChange(task: MesProTaskApi.Task | undefined) {
  selectedTask.value = task;
  selectedTaskId.value = task?.id;
}
async function handleSubmit() {
  const taskId = selectedTaskId.value;
  if (!taskId) {
    message.warning('请选择任务');
    return;
  }
  if (!formData.value.feedbackQuantity) {
    message.warning('请输入报工数量');
    return;
  }
  submitting.value = true;
  try {
    await quickCreateFeedback({
      taskId,
      feedbackQuantity: formData.value.feedbackQuantity,
      qualifiedQuantity: formData.value.qualifiedQuantity,
      unqualifiedQuantity: formData.value.unqualifiedQuantity,
      remark: formData.value.remark || undefined,
    });
    message.success('报工成功');
    modalApi.close();
    emit('success');
  } finally {
    submitting.value = false;
  }
}
</script>
<template>
  <Modal title="快速报工" @ok="handleSubmit">
    <a-form ref="formRef" :model="formData" layout="vertical">
      <a-form-item label="任务" required>
        <ProTaskSelect
          v-if="!hasPredefinedTask"
          v-model="selectedTaskId"
          @change="handleTaskChange"
        />
        <a-input v-else :value="props.taskCode" disabled />
      </a-form-item>
      <a-form-item label="待报工数量">
        <a-input-number
          :value="pendingQuantity"
          disabled
          class="!w-full"
        />
      </a-form-item>
      <a-form-item label="本次报工数量" required>
        <a-input-number
          v-model:value="formData.feedbackQuantity"
          :min="0.01"
          :max="pendingQuantity || undefined"
          :precision="2"
          placeholder="请输入报工数量"
          class="!w-full"
        />
      </a-form-item>
      <a-form-item label="合格品数量">
        <a-input-number
          v-model:value="formData.qualifiedQuantity"
          :min="0"
          :precision="2"
          placeholder="请输入合格品数量"
          class="!w-full"
        />
      </a-form-item>
      <a-form-item label="不良品数量">
        <a-input-number
          v-model:value="formData.unqualifiedQuantity"
          :min="0"
          :precision="2"
          placeholder="请输入不良品数量"
          class="!w-full"
        />
      </a-form-item>
      <a-form-item label="备注">
        <a-textarea
          v-model:value="formData.remark"
          placeholder="请输入备注"
          :rows="2"
        />
      </a-form-item>
    </a-form>
  </Modal>
</template>
src/views/mes/pro/workorder-center/modules/schedule-preview.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,86 @@
<script lang="ts" setup>
import type { AutoGeneratePreview } from '#/api/mes/pro/task';
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Tag } from 'ant-design-vue';
import { autoGenerateTasks } from '#/api/mes/pro/task';
defineOptions({ name: 'SchedulePreviewModal' });
interface Props {
  workOrder?: MesProWorkOrderApi.WorkOrder;
  preview?: AutoGeneratePreview;
}
const props = withDefaults(defineProps<Props>(), {});
interface Emits {
  (e: 'success'): void;
}
const emit = defineEmits<Emits>();
const [Modal, modalApi] = useVbenModal({
  onOpenChange: (open) => {
    if (!open) return;
  },
});
const executing = ref(false);
async function handleExecute() {
  if (!props.workOrder?.id) return;
  executing.value = true;
  try {
    const result = await autoGenerateTasks({
      workOrderId: props.workOrder.id,
      routeId: props.preview?.routeId,
    });
    message.success(`排产成功,生成 ${result.taskIds?.length || 0} ä¸ªä»»åŠ¡`);
    modalApi.close();
    emit('success');
  } finally {
    executing.value = false;
  }
}
</script>
<template>
  <Modal title="一键排产预览" @ok="handleExecute">
    <a-descriptions :column="2" bordered size="small" class="mb-4">
      <a-descriptions-item label="工单编码">{{ props.workOrder?.code }}</a-descriptions-item>
      <a-descriptions-item label="生产数量">{{ props.workOrder?.quantity }}</a-descriptions-item>
      <a-descriptions-item label="工艺路线">{{ props.preview?.routeName }}</a-descriptions-item>
      <a-descriptions-item label="预生成任务数">{{ props.preview?.taskList?.length || 0 }}</a-descriptions-item>
    </a-descriptions>
    <vxe-grid
      :columns="[
        { field: 'sort', title: '序号', width: 60 },
        { field: 'processName', title: '工序名称', minWidth: 120 },
        {
          field: 'keyFlag',
          title: '关键工序',
          width: 80,
          slots: { default: 'keyFlag' },
        },
        { field: 'suggestWorkstationName', title: '建议工作站', width: 140 },
        { field: 'quantity', title: '排产数量', width: 100 },
      ]"
      :data="props.preview?.taskList || []"
      height="auto"
      size="small"
    >
      <template #keyFlag="{ row: taskRow }">
        <Tag :color="taskRow.keyFlag ? 'blue' : 'default'">
          {{ taskRow.keyFlag ? '是' : '否' }}
        </Tag>
      </template>
    </vxe-grid>
  </Modal>
</template>
src/views/mes/pro/workorder/data.ts
@@ -3,6 +3,7 @@
import type { MesMdProductBomApi } from '#/api/mes/md/item/productBom';
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
import type { MesProWorkOrderBomApi } from '#/api/mes/pro/workorder/bom';
import type { MesProWorkOrderProcessApi } from '#/api/mes/pro/workorder/process';
import { h, markRaw } from 'vue';
@@ -734,3 +735,92 @@
    },
  ];
}
/** è®¢å•工序列表的字段 */
export function useProcessGridColumns(): VxeTableGridOptions<MesProWorkOrderProcessApi.WorkOrderProcess>['columns'] {
  return [
    {
      field: 'sort',
      title: '序号',
      width: 80,
    },
    {
      field: 'processCode',
      title: '工序编码',
      width: 120,
    },
    {
      field: 'processName',
      title: '工序名称',
      minWidth: 150,
    },
    {
      field: 'nextProcessName',
      title: '下一道工序',
      width: 120,
    },
    {
      field: 'linkType',
      title: '工序关系',
      width: 100,
      formatter: ({ cellValue }) => {
        const map: Record<number, string> = {
          0: '开始-开始',
          1: '结束-结束',
          2: '开始-结束',
          3: '结束-开始',
        };
        return map[cellValue] ?? '-';
      },
    },
    {
      field: 'prepareTime',
      title: '准备时间(分)',
      width: 100,
    },
    {
      field: 'waitTime',
      title: '等待时间(分)',
      width: 100,
    },
    {
      field: 'keyFlag',
      title: '关键工序',
      width: 80,
      slots: { default: 'keyFlag' },
    },
    {
      field: 'checkFlag',
      title: '质检工序',
      width: 80,
      slots: { default: 'checkFlag' },
    },
    {
      field: 'backflushFlag',
      title: '倒冲',
      width: 80,
      slots: { default: 'backflushFlag' },
    },
    {
      field: 'outputItemCode',
      title: '产出物料编码',
      width: 120,
    },
    {
      field: 'outputItemName',
      title: '产出物料名称',
      width: 120,
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 120,
    },
    {
      title: '操作',
      width: 120,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/mes/pro/workorder/modules/form.vue
@@ -3,6 +3,7 @@
  import type { MesProWorkOrderApi } from "#/api/mes/pro/workorder";
  import type { MesProWorkOrderBomApi } from "#/api/mes/pro/workorder/bom";
  import type { MesProWorkOrderProcessApi } from "#/api/mes/pro/workorder/process";
  import { computed, ref } from "vue";
@@ -22,29 +23,33 @@
    getWorkOrder,
    updateWorkOrder,
  } from "#/api/mes/pro/workorder";
  import { batchSaveWorkOrderProcess } from "#/api/mes/pro/workorder/process";
  import { $t } from "#/locales";
  import { BarcodeDetail } from "#/views/wls/barcode/components";
  import { useFormSchema } from "../data";
  import BomList from "./bom-list.vue";
  import ItemList from "./item-list.vue";
  import ProcessList from "./process-list.vue";
  const emit = defineEmits(["success"]);
  const formType = ref<FormType>("create");
  const formData = ref<MesProWorkOrderApi.WorkOrder>();
  const originalSnapshot = ref(""); // è¡¨å•原始数据快照,用于确认时跳过未变更的保存请求
  const subTabsName = ref("bom"); // å½“前选中的子表 Tab
  const barcodeDetailRef = ref<InstanceType<typeof BarcodeDetail>>(); // æ¡ç è¯¦æƒ…弹窗
  const originalSnapshot = ref("");
  const subTabsName = ref("bom");
  const barcodeDetailRef = ref<InstanceType<typeof BarcodeDetail>>();
  const processListRef = ref<InstanceType<typeof ProcessList>>();
  const currentProductId = ref<number>(); // å½“前选中的产品 ID(用于工序加载)
  const tempProcessList = ref<MesProWorkOrderProcessApi.WorkOrderProcess[]>([]); // ä¸´æ—¶å·¥åºåˆ—表
  const isEditable = computed(() =>
    // æ˜¯å¦ä¸ºç¼–辑模式(可保存)
    ["create", "update"].includes(formType.value)
  );
  const isConfirm = computed(() => formType.value === "confirm"); // æ˜¯å¦ä¸ºç¡®è®¤æ¨¡å¼
  const isFinish = computed(() => formType.value === "finish"); // æ˜¯å¦ä¸ºå®Œæˆæ¨¡å¼
  const isConfirm = computed(() => formType.value === "confirm");
  const isFinish = computed(() => formType.value === "finish");
  const isCreateMode = computed(() => formType.value === "create");
  const canConfirm = computed(
    () =>
      // ç¼–辑态草稿可确认
      formType.value === "update" &&
      formData.value?.status === MesProWorkOrderStatusEnum.PREPARE
  );
@@ -69,6 +74,9 @@
    }
  });
  // åˆ¤æ–­æ˜¯å¦æ˜¾ç¤º Tab(新增模式或已有 ID)
  const showTabs = computed(() => isCreateMode.value || formData.value?.id);
  const [Form, formApi] = useVbenForm({
    commonConfig: {
      componentProps: {
@@ -81,6 +89,15 @@
    schema: [],
    showDefaultActions: false,
    wrapperClass: "grid-cols-3",
    handleValuesChange: (values, fieldsChanged) => {
      // äº§å“å˜æ›´æ—¶ï¼Œè§¦å‘工序加载
      if (isCreateMode.value && fieldsChanged.includes('productId')) {
        const newProductId = values.productId;
        if (newProductId && newProductId !== currentProductId.value) {
          currentProductId.value = newProductId;
        }
      }
    },
  });
  /** é‡æ–°æŒ‚è½½ schema å¹¶æŒ‰è¡¨å•态控制底部按钮 */
@@ -110,7 +127,7 @@
    );
  }
  /** ä»Ž BOM ç‰©æ–™è¡Œé¢„填子订单:复用当前订单上下文,进入新增态 */
  /** ä»Ž BOM ç‰©æ–™è¡Œé¢„填子订单 */
  async function handleGenerateWorkOrder(
    bomRow: MesProWorkOrderBomApi.WorkOrderBom
  ) {
@@ -137,8 +154,15 @@
      type: current.type,
      vendorId: current.vendorId,
    });
    // è®¾ç½®å½“前产品 ID,触发工序加载
    currentProductId.value = bomRow.itemId;
    originalSnapshot.value = JSON.stringify(await formApi.getValues());
    message.info("已从 BOM ç‰©æ–™é¢„填子订单,请补充订单编码等信息后保存");
  }
  /** å·¥åºåˆ—表变化回调 */
  function handleProcessListChange(list: MesProWorkOrderProcessApi.WorkOrderProcess[]) {
    tempProcessList.value = list;
  }
  const [Modal, modalApi] = useVbenModal({
@@ -154,7 +178,7 @@
        }
        modalApi.lock();
        try {
          // ç¼–辑态有修改时先保存
          // 1. ä¿å­˜å·¥å•信息(如果有变更)
          if (isEditable.value) {
            const current = JSON.stringify(await formApi.getValues());
            if (current !== originalSnapshot.value) {
@@ -163,6 +187,15 @@
              originalSnapshot.value = current;
            }
          }
          // 2. æ‰¹é‡ä¿å­˜å·¥åºï¼ˆå¦‚果有)
          if (tempProcessList.value.length > 0) {
            const processList = tempProcessList.value.map(item => ({
              ...item,
              workOrderId: formData.value!.id,
            }));
            await batchSaveWorkOrderProcess(processList);
          }
          // 3. ç¡®è®¤å·¥å•
          await confirmWorkOrder(formData.value.id);
          message.success('订单已确认');
          await modalApi.close();
@@ -202,13 +235,31 @@
      const data = (await formApi.getValues()) as MesProWorkOrderApi.WorkOrder;
      try {
        if (formData.value?.id) {
          // ç¼–辑模式
          await updateWorkOrder({ ...formData.value, ...data });
          // æ‰¹é‡ä¿å­˜å·¥åº
          if (tempProcessList.value.length > 0) {
            const processList = tempProcessList.value.map(item => ({
              ...item,
              workOrderId: formData.value!.id,
            }));
            await batchSaveWorkOrderProcess(processList);
          }
          formData.value = { ...formData.value, ...data };
          emit("success");
          message.success($t("ui.actionMessage.operationSuccess"));
          await modalApi.close();
        } else {
          // æ–°å¢žæ¨¡å¼
          const id = await createWorkOrder(data);
          // æ‰¹é‡ä¿å­˜å·¥åº
          if (tempProcessList.value.length > 0) {
            const processList = tempProcessList.value.map(item => ({
              ...item,
              workOrderId: id,
            }));
            await batchSaveWorkOrderProcess(processList);
          }
          formData.value = {
            ...data,
            id,
@@ -228,9 +279,10 @@
      if (!isOpen) {
        formData.value = undefined;
        originalSnapshot.value = "";
        currentProductId.value = undefined;
        tempProcessList.value = [];
        return;
      }
      // åŠ è½½æ•°æ®
      const data = modalApi.getData<{
        formType: FormType;
        id?: number;
@@ -238,18 +290,18 @@
      }>();
      formType.value = data.formType;
      subTabsName.value = "bom";
      tempProcessList.value = [];
      applySchema();
      if (data?.id) {
        modalApi.lock();
        try {
          formData.value = await getWorkOrder(data.id);
          // è®¾ç½®åˆ° values
          await formApi.setValues(formData.value);
          currentProductId.value = formData.value.productId;
        } finally {
          modalApi.unlock();
        }
      } else if (data?.parentRow) {
        // æ–°å¢žå­è®¢å•时,预填父订单信息
        const parent = data.parentRow;
        await formApi.setValues({
          clientId: parent.clientId,
@@ -270,20 +322,39 @@
  <Modal :title="getTitle"
         class="w-3/5">
    <Form class="mx-4" />
    <!-- BOM / ç‰©æ–™éœ€æ±‚ Tab:非新建态展示 -->
    <template v-if="formData?.id">
    <!-- BOM / å·¥åº / ç‰©æ–™éœ€æ±‚ Tab -->
    <template v-if="showTabs">
      <Tabs v-model:active-key="subTabsName"
            class="mx-4">
        <Tabs.TabPane key="bom"
                      tab="订单 BOM">
          <BomList :form-type="formType"
          <BomList v-if="formData?.id"
                   :form-type="formType"
                   :work-order="formData"
                   :work-order-id="formData.id"
                   @generate-work-order="handleGenerateWorkOrder" />
          <div v-else class="text-gray-400 text-center py-8">
            è¯·å…ˆä¿å­˜å·¥å•后再配置 BOM
          </div>
        </Tabs.TabPane>
        <Tabs.TabPane key="process"
                      tab="工序">
          <ProcessList
            ref="processListRef"
            :form-type="formType"
            :work-order="formData || {}"
            :work-order-id="formData?.id"
            :product-id="currentProductId"
            @process-list-change="handleProcessListChange"
          />
        </Tabs.TabPane>
        <Tabs.TabPane key="item"
                      tab="物料需求">
          <ItemList :work-order-id="formData.id" />
          <ItemList v-if="formData?.id"
                    :work-order-id="formData.id" />
          <div v-else class="text-gray-400 text-center py-8">
            è¯·å…ˆä¿å­˜å·¥å•后再查看物料需求
          </div>
        </Tabs.TabPane>
      </Tabs>
    </template>
src/views/mes/pro/workorder/modules/process-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,319 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { MesProWorkOrderProcessApi } from '#/api/mes/pro/workorder/process';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getProcessSimpleList } from '#/api/mes/pro/process';
import {
  createWorkOrderProcess,
  getWorkOrderProcess,
  updateWorkOrderProcess,
} from '#/api/mes/pro/workorder/process';
import { $t } from '#/locales';
import { MdItemSelect } from '#/views/mes/md/item/components';
const emit = defineEmits(['success']);
const formData = ref<MesProWorkOrderProcessApi.WorkOrderProcess>();
const workOrderId = ref<number>();
const processList = ref<MesProWorkOrderProcessApi.WorkOrderProcess[]>([]);
const isCreateMode = ref(false);
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['工序'])
    : $t('ui.actionTitle.create', ['工序']);
});
/** åŠ è½½å·¥åºé€‰é¡¹ */
async function loadProcessOptions() {
  const list = await getProcessSimpleList();
  return (list || []).map((item) => ({
    label: item.name ?? '',
    value: item.id!,
  }));
}
/** è¡¨å• schema */
async function loadSchema(): Promise<VbenFormSchema[]> {
  const processOptions = await loadProcessOptions();
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'workOrderId',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'processId',
      label: '工序',
      component: 'Select',
      componentProps: {
        options: processOptions,
        placeholder: '请选择工序',
        onChange: async (value: number) => {
          const process = processOptions.find((item) => item.value === value);
          if (process) {
            await formApi.setFieldValue('processName', process.label);
          }
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'processName',
      label: '工序名称',
      component: 'Input',
      componentProps: { disabled: true },
    },
    {
      fieldName: 'sort',
      label: '序号',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 1,
        placeholder: '请输入序号',
        precision: 0,
      },
      rules: 'required',
    },
    {
      fieldName: 'linkType',
      label: '工序关系',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: [
          { label: '开始-开始', value: 0 },
          { label: '结束-结束', value: 1 },
          { label: '开始-结束', value: 2 },
          { label: '结束-开始', value: 3 },
        ],
        placeholder: '请选择工序关系',
      },
    },
    {
      fieldName: 'prepareTime',
      label: '准备时间(分钟)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        placeholder: '请输入准备时间',
        precision: 0,
      },
    },
    {
      fieldName: 'waitTime',
      label: '等待时间(分钟)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        placeholder: '请输入等待时间',
        precision: 0,
      },
    },
    {
      fieldName: 'colorCode',
      label: '甘特图颜色',
      component: 'Input',
      componentProps: {
        placeholder: '请选择颜色',
        type: 'color',
      },
    },
    {
      fieldName: 'keyFlag',
      label: '是否关键工序',
      component: 'RadioGroup',
      componentProps: {
        options: [
          { label: '否', value: false },
          { label: '是', value: true },
        ],
      },
      defaultValue: false,
    },
    {
      fieldName: 'checkFlag',
      label: '是否质检工序',
      component: 'RadioGroup',
      componentProps: {
        options: [
          { label: '否', value: false },
          { label: '是', value: true },
        ],
      },
      defaultValue: false,
    },
    {
      fieldName: 'backflushFlag',
      label: '是否倒冲',
      component: 'RadioGroup',
      componentProps: {
        options: [
          { label: '否', value: false },
          { label: '是', value: true },
        ],
      },
      defaultValue: true,
    },
    {
      fieldName: 'outputItemId',
      label: '产出物料',
      component: MdItemSelect,
      componentProps: {
        placeholder: '请选择产出物料',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        placeholder: '请输入备注',
        rows: 2,
      },
    },
  ];
}
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 130,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as MesProWorkOrderProcessApi.WorkOrderProcess;
    data.workOrderId = workOrderId.value;
    // æ ¡éªŒåºå·å”¯ä¸€
    const isSortDuplicate = processList.value.some(
      (item) => item.sort === data.sort && item.processId !== data.processId
    );
    if (isSortDuplicate) {
      message.error('序号已存在');
      modalApi.unlock();
      return;
    }
    // æ ¡éªŒå…³é”®å·¥åºå”¯ä¸€
    if (data.keyFlag) {
      const hasKeyProcess = processList.value.some(
        (item) => item.keyFlag && item.processId !== data.processId
      );
      if (hasKeyProcess) {
        message.error('已存在关键工序,每个工单只能有一个关键工序');
        modalApi.unlock();
        return;
      }
    }
    try {
      if (isCreateMode.value) {
        // æ–°å¢žæ¨¡å¼ï¼šç›´æŽ¥è¿”回数据给父组件
        emit('success', data, !!formData.value?.processId);
        await modalApi.close();
      } else if (formData.value?.id) {
        // ç¼–辑模式:调用更新接口
        await updateWorkOrderProcess(data);
        emit('success', data, true);
        await modalApi.close();
        message.success($t('ui.actionMessage.operationSuccess'));
      } else {
        // æ–°å¢žæ¨¡å¼ï¼ˆæœ‰ workOrderId):调用创建接口
        await createWorkOrderProcess(data);
        emit('success', data, false);
        await modalApi.close();
        message.success($t('ui.actionMessage.operationSuccess'));
      }
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      processList.value = [];
      isCreateMode.value = false;
      return;
    }
    modalApi.lock();
    try {
      const schema = await loadSchema();
      formApi.setState({ schema });
    } finally {
      modalApi.unlock();
    }
    const data = modalApi.getData<{
      id?: number;
      productId?: number;
      workOrderId?: number;
      row?: MesProWorkOrderProcessApi.WorkOrderProcess;
      processList?: MesProWorkOrderProcessApi.WorkOrderProcess[];
    }>();
    if (!data) {
      return;
    }
    workOrderId.value = data.workOrderId;
    processList.value = data.processList || [];
    isCreateMode.value = !data.workOrderId;
    if (data.row) {
      // ç¼–辑临时列表中的工序
      formData.value = data.row;
      await formApi.setValues(data.row);
    } else if (data.id) {
      // ç¼–辑已保存的工序
      modalApi.lock();
      try {
        formData.value = await getWorkOrderProcess(data.id);
        await formApi.setValues(formData.value);
      } finally {
        modalApi.unlock();
      }
    } else {
      // æ–°å¢ž
      formData.value = undefined;
      const maxSort = Math.max(0, ...processList.value.map((item) => item.sort || 0));
      await formApi.setValues({
        colorCode: '#00AEF3',
        sort: maxSort + 1,
      });
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/mes/pro/workorder/modules/process-list.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,236 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
import type { MesProWorkOrderProcessApi } from '#/api/mes/pro/workorder/process';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { MesProWorkOrderStatusEnum } from '@vben/constants';
import { message, Tag } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteWorkOrderProcess,
  getWorkOrderProcessListByWorkOrderId,
} from '#/api/mes/pro/workorder/process';
import { getRouteProcessListByProduct } from '#/api/mes/pro/route/process';
import { $t } from '#/locales';
import { useProcessGridColumns } from '../data';
import ProcessForm from './process-form.vue';
const props = defineProps<{
  formType: FormType;
  workOrder: MesProWorkOrderApi.WorkOrder;
  workOrderId?: number;
  productId?: number;
}>();
const emit = defineEmits<{
  processListChange: [list: MesProWorkOrderProcessApi.WorkOrderProcess[]];
}>();
// å·¥åºåˆ—表数据
const list = ref<MesProWorkOrderProcessApi.WorkOrderProcess[]>([]);
const isEditable = computed(() =>
  ['create', 'update'].includes(props.formType) &&
  (props.workOrder?.status === MesProWorkOrderStatusEnum.PREPARE ||
   props.formType === 'create'),
);
const isCreateMode = computed(() => props.formType === 'create');
const [ProcessFormModal, processFormModalApi] = useVbenModal({
  connectedComponent: ProcessForm,
  destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    autoResize: true,
    border: true,
    columns: useProcessGridColumns(),
    data: [],
    minHeight: 240,
    pagerConfig: { enabled: false },
    rowConfig: { isHover: true, keyField: 'processId' },
    showOverflow: true,
    toolbarConfig: { enabled: false },
  } as VxeTableGridOptions<MesProWorkOrderProcessApi.WorkOrderProcess>,
});
/** åŠ è½½å·¥åºåˆ—è¡¨ */
async function getList() {
  gridApi.setLoading(true);
  try {
    if (isCreateMode.value) {
      // æ–°å¢žæ¨¡å¼ï¼šä»Žå·¥è‰ºè·¯çº¿èŽ·å–
      if (!props.productId) {
        list.value = [];
      } else {
        // è°ƒç”¨å·¥è‰ºè·¯çº¿æŽ¥å£èŽ·å–å·¥åºåˆ—è¡¨
        const routeProcessList = await getRouteProcessListByProduct(props.productId);
        // è½¬æ¢ä¸ºå·¥å•工序格式
        list.value = routeProcessList.map((item) => ({
          processId: item.processId,
          processCode: item.processCode,
          processName: item.processName,
          sort: item.sort,
          nextProcessId: item.nextProcessId,
          nextProcessName: item.nextProcessName,
          linkType: item.linkType,
          prepareTime: item.prepareTime,
          waitTime: item.waitTime,
          colorCode: item.colorCode,
          keyFlag: item.keyFlag,
          checkFlag: item.checkFlag,
          backflushFlag: item.backflushFlag,
          outputItemId: item.outputItemId,
          outputItemCode: item.outputItemCode,
          outputItemName: item.outputItemName,
          remark: item.remark,
        }));
      }
    } else {
      // ç¼–辑模式:从工单工序获取
      if (!props.workOrderId) {
        list.value = [];
      } else {
        list.value = await getWorkOrderProcessListByWorkOrderId(props.workOrderId);
      }
    }
    gridApi.setGridOptions({ data: list.value });
    emit('processListChange', list.value);
  } finally {
    gridApi.setLoading(false);
  }
}
/** æ·»åŠ å·¥åº */
function handleCreate() {
  processFormModalApi
    .setData({
      workOrderId: props.workOrderId,
      productId: props.productId || props.workOrder?.productId,
      processList: list.value,
    })
    .open();
}
/** ç¼–辑工序 */
function handleEdit(row: MesProWorkOrderProcessApi.WorkOrderProcess) {
  processFormModalApi
    .setData({
      id: row.id,
      workOrderId: props.workOrderId,
      productId: props.productId || props.workOrder?.productId,
      row,
      processList: list.value,
    })
    .open();
}
/** åˆ é™¤å·¥åº */
function handleDelete(row: MesProWorkOrderProcessApi.WorkOrderProcess) {
  if (isCreateMode.value) {
    // æ–°å¢žæ¨¡å¼ï¼šä»Žåˆ—表删除
    const index = list.value.findIndex((item) => item.processId === row.processId);
    if (index > -1) {
      list.value.splice(index, 1);
      gridApi.setGridOptions({ data: list.value });
      emit('processListChange', list.value);
    }
  } else {
    // ç¼–辑模式:调用接口删除
    deleteWorkOrderProcess(row.id!).then(() => {
      message.success($t('ui.actionMessage.deleteSuccess', [row.processName]));
      getList();
    });
  }
}
/** æ–°å¢ž/编辑成功回调 */
function handleFormSuccess(data: MesProWorkOrderProcessApi.WorkOrderProcess, isEdit: boolean) {
  if (isCreateMode.value) {
    if (isEdit) {
      const index = list.value.findIndex((item) => item.processId === data.processId);
      if (index > -1) {
        list.value[index] = data;
      }
    } else {
      list.value.push(data);
    }
    gridApi.setGridOptions({ data: list.value });
    emit('processListChange', list.value);
  } else {
    getList();
  }
}
// ç›‘听产品/工单变化
watch(
  () => [props.productId, props.workOrderId],
  () => {
    getList();
  },
  { immediate: true },
);
</script>
<template>
  <div>
    <ProcessFormModal @success="handleFormSuccess" />
    <div v-if="isEditable" class="mb-3 flex items-center justify-start">
      <TableAction
        :actions="[
          {
            label: '添加工序',
            type: 'primary',
            onClick: handleCreate,
          },
        ]"
      />
    </div>
    <Grid class="w-full" table-title="工序">
      <template #keyFlag="{ row }">
        <Tag v-if="row.keyFlag" color="red">关键</Tag>
        <span v-else>-</span>
      </template>
      <template #checkFlag="{ row }">
        <Tag v-if="row.checkFlag" color="blue">质检</Tag>
        <span v-else>-</span>
      </template>
      <template #backflushFlag="{ row }">
        <Tag v-if="row.backflushFlag" color="green">是</Tag>
        <Tag v-else color="default">否</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              ifShow: isEditable,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              ifShow: isEditable,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.processName]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </div>
</template>