已修改21个文件
已添加31个文件
4215 ■■■■■ 文件已修改
.env.production 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
company.config.ts 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/bag-code-home/index.ts 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/bagCode/index.ts 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/batch/index.ts 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/line/index.ts 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/pallet/index.ts 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/table-action/table-action.vue 62 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main.ts 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/preferences/src/config.ts 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/ui-kit/layout-ui/src/vben-layout.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-mes-enum.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/modules/bagcode-home.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/modules/dashboard.ts 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/components/data.ts 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/components/select-dialog.vue 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/components/select.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/data.ts 28 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/index.vue 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/modules/form.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/chart-options.ts 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/data.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/index.vue 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/bag-code-cards.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/bag-code-status-chart.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/basic-cards.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/batch-status-chart.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/shortcuts.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/trend-chart.vue 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/select-dialog.vue 24 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/select.vue 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/data.ts 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/type/data.ts 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/type/index.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/type/modules/form.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/data.ts 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/index.vue 273 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/modules/bind-pallet.vue 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/modules/generate-form.vue 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/data.ts 336 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/index.vue 238 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/modules/form.vue 281 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/components/qr-trace-modal.vue 569 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/line/components/index.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/line/components/select.vue 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/line/data.ts 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/line/index.vue 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/line/modules/form.vue 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/pallet/data.ts 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/pallet/index.vue 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/pallet/modules/generate-form.vue 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env.production
@@ -3,9 +3,9 @@
VITE_BASE=/
# è¯·æ±‚路径
VITE_BASE_URL=http://36.213.180.217:9501
VITE_BASE_URL=http://47.97.181.254:9001
# æŽ¥å£åœ°å€
VITE_GLOB_API_URL=http://36.213.180.217:9501/admin-api
VITE_GLOB_API_URL=http://47.97.181.254:9001/admin-api
# æ–‡ä»¶ä¸Šä¼ ç±»åž‹ï¼šserver - åŽç«¯ä¸Šä¼ ï¼Œ client - å‰ç«¯ç›´è¿žä¸Šä¼ ï¼Œä»…支持S3服务
VITE_UPLOAD_TYPE=server
company.config.ts
@@ -18,7 +18,7 @@
 */
export const companies: Record<string, CompanyConfig> = {
  TEST: {
    name: '测试公司',
    name: '新疆塔河种业',
    logo: '/logos/TEST.png',
    favicon: '/logos/TEST.ico',
    apiUrl: 'http://36.213.180.217:9501/admin-api',
src/api/mes/bag-code-home/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
import { requestClient } from '#/api/request';
export namespace MesBagCodeHomeApi {
  /** åŸºç¡€æ•°æ®ä¸Žä¸€è¢‹ä¸€ç  çŠ¶æ€åˆ†å¸ƒé¡¹ */
  export interface StatusItem {
    status: number; // çŠ¶æ€å€¼
    count: number; // æ•°é‡
  }
  /** åŸºç¡€æ•°æ®ä¸Žä¸€è¢‹ä¸€ç  é¦–页汇总统计 */
  export interface Summary {
    mdmItemCount: number; // å“ç§æ€»æ•°ï¼ˆmdm_item)
    mesItemCount: number; // MES å“ç§æ•°ï¼ˆmes_md_item)
    itemTypeCount: number; // å“ç§åˆ†ç±»æ•°
    unitMeasureCount: number; // è®¡é‡å•位数
    lineCount: number; // äº§çº¿æ•°
    batchCount: number; // æ‰¹æ¬¡æ€»æ•°
    bagCodeCount: number; // è¢‹ç æ€»æ•°
    palletCount: number; // æ‰˜ç›˜ç æ€»æ•°
    batchStatusList: StatusItem[]; // æ‰¹æ¬¡çŠ¶æ€åˆ†å¸ƒ
    bagCodeStatusList: StatusItem[]; // è¢‹ç çŠ¶æ€åˆ†å¸ƒ
  }
  /** åŸºç¡€æ•°æ®ä¸Žä¸€è¢‹ä¸€ç  è¢‹ç ç”Ÿæˆè¶‹åŠ¿ */
  export interface Trend {
    date: string; // æ—¥æœŸ
    count: number; // è¢‹ç ç”Ÿæˆæ•°é‡
  }
}
/** èŽ·å¾—åŸºç¡€æ•°æ®ä¸Žä¸€è¢‹ä¸€ç  é¦–页汇总统计 */
export function getBagCodeHomeSummary() {
  return requestClient.get<MesBagCodeHomeApi.Summary>(
    '/mes/bag-code-home-statistics/summary',
  );
}
/** èŽ·å¾—è¢‹ç ç”Ÿæˆè¶‹åŠ¿ */
export function getBagCodeHomeTrend(days?: number) {
  return requestClient.get<MesBagCodeHomeApi.Trend[]>(
    '/mes/bag-code-home-statistics/trend',
    { params: { days } },
  );
}
src/api/mes/pro/bagCode/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,129 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MesProBagCodeApi {
  /** è¢‹ç ï¼ˆä¸€è¢‹ä¸€ç ï¼‰ */
  export interface BagCode {
    id?: number; // ç¼–号
    uuid?: string; // ç”Ÿæˆç»„ UUID
    code?: string; // è¢‹ç ï¼ˆäºŒç»´ç å†…容)
    bagNo?: number; // æ‰¹æ¬¡å†…袋序号
    batchId?: number; // ç”Ÿäº§æ‰¹æ¬¡ç¼–号
    batchCode?: string; // æ‰¹æ¬¡å·
    lineCode?: string; // äº§çº¿ç¼–码
    lineName?: string; // äº§çº¿åç§°
    palletId?: number; // ç»‘定托盘编号
    palletCode?: string; // æ‰˜ç›˜ç 
    status?: number; // çŠ¶æ€ï¼ˆå­—å…¸ mes_pro_bag_code_status)
    printTime?: string; // å¯¼å‡ºï¼ˆäº¤ä»˜å°åˆ·ï¼‰æ—¶é—´
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
  }
  /** è¢‹ç åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    code?: string;
    batchId?: number;
    batchCode?: string;
    uuid?: string;
    palletId?: number;
    status?: number;
    createTime?: string[];
  }
  /** ç”Ÿæˆæ•°æ®åŒ…请求 */
  export interface GenerateParams {
    batchId: number; // ç”Ÿäº§æ‰¹æ¬¡ç¼–号
    count: number; // ç”Ÿæˆè¢‹æ•°
    remark?: string; // å¤‡æ³¨
  }
  /** ç»‘定/解绑托盘请求 */
  export interface BindParams {
    palletId: number; // æ‰˜ç›˜ç¼–号
    ids: number[]; // è¢‹ç ç¼–号列表
  }
  /** æ‰«ç è¿½æº¯ä¿¡æ¯ */
  export interface BagCodeTrace {
    id?: number; // è¢‹ç ç¼–号
    code?: string; // è¢‹ç ï¼ˆäºŒç»´ç å†…容)
    bagNo?: number; // è¢‹åºå·
    status?: number; // è¢‹ç çŠ¶æ€
    printTime?: string; // å¯¼å‡ºå°åˆ·æ—¶é—´
    batchId?: number; // æ‰¹æ¬¡ç¼–号
    batchCode?: string; // æ‰¹æ¬¡å·
    batchStatus?: number; // æ‰¹æ¬¡çŠ¶æ€
    produceDate?: string; // ç”Ÿäº§æ—¥æœŸ
    lineCode?: string; // äº§çº¿ç¼–码
    lineName?: string; // äº§çº¿åç§°
    ownerUserId?: number; // è´£ä»»äººç¼–号
    ownerUserName?: string; // è´£ä»»äººæ˜µç§°
    palletId?: number; // æ‰˜ç›˜ç¼–号
    palletCode?: string; // æ‰˜ç›˜ç 
    itemId?: number; // å“ç§ï¼ˆç‰©æ–™ï¼‰ç¼–号
    itemCode?: string; // å“ç§ç¼–码
    itemName?: string; // å“ç§åç§°
    itemSpecification?: string; // å“ç§è§„æ ¼
  }
}
/** ç”Ÿæˆè¢‹ç æ•°æ®åŒ…(返回生成组 UUID) */
export function generateBagCodes(data: MesProBagCodeApi.GenerateParams) {
  return requestClient.post<string>('/mes/pro/bag-code/generate', data);
}
/** åˆ é™¤è¢‹ç ç”Ÿæˆç»„ */
export function deleteBagCodesByUuid(uuid: string) {
  return requestClient.delete(`/mes/pro/bag-code/delete-by-uuid?uuid=${uuid}`);
}
/** è¢‹ç ç»‘定托盘 */
export function bindBagCodePallet(data: MesProBagCodeApi.BindParams) {
  return requestClient.put('/mes/pro/bag-code/bind-pallet', data);
}
/** è¢‹ç è§£ç»‘托盘 */
export function unbindBagCodePallet(data: MesProBagCodeApi.BindParams) {
  return requestClient.put('/mes/pro/bag-code/unbind-pallet', data);
}
/** æŸ¥è¯¢è¢‹ç è¯¦æƒ… */
export function getBagCode(id: number) {
  return requestClient.get<MesProBagCodeApi.BagCode>(
    `/mes/pro/bag-code/get?id=${id}`,
  );
}
/** æ‰«ç è¿½æº¯ï¼šæ ¹æ®è¢‹ç åæŸ¥æ‰¹æ¬¡ã€äº§çº¿ã€è´£ä»»äººç­‰ä¿¡æ¯ */
export function getBagCodeByCode(code: string) {
  return requestClient.get<MesProBagCodeApi.BagCodeTrace>(
    `/mes/pro/bag-code/get-by-code?code=${encodeURIComponent(code)}`,
  );
}
/** æŸ¥è¯¢è¢‹ç åˆ†é¡µ */
export function getBagCodePage(params: MesProBagCodeApi.PageParams) {
  return requestClient.get<PageResult<MesProBagCodeApi.BagCode>>(
    '/mes/pro/bag-code/page',
    { params },
  );
}
/** æŸ¥è¯¢ç”Ÿæˆç»„的袋码列表 */
export function getBagCodeListByUuid(uuid: string) {
  return requestClient.get<MesProBagCodeApi.BagCode[]>(
    `/mes/pro/bag-code/list-by-uuid?uuid=${uuid}`,
  );
}
/** æ‰¹é‡å¯¼å‡ºè¢‹ç  Excel(交付印刷) */
export function exportBagCodeExcel(params: MesProBagCodeApi.PageParams) {
  return requestClient.download('/mes/pro/bag-code/export-excel', { params });
}
/** æ‰¹é‡å¯¼å‡ºè¢‹ç äºŒç»´ç å›¾ç‰‡ ZIP(uuid ä¼˜å…ˆï¼Œå¦åˆ™æŒ‰ batchId) */
export function exportBagQr(params: { uuid?: string; batchId?: number }) {
  return requestClient.download('/mes/pro/bag-code/export-qr', { params });
}
src/api/mes/pro/batch/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,136 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
import type { MesProBagCodeApi } from '#/api/mes/pro/bagCode';
import type { MesProPalletApi } from '#/api/mes/pro/pallet';
export namespace MesProBatchApi {
  /** ç”Ÿäº§æ‰¹æ¬¡äººå‘˜é…ç½® */
  export interface BatchWorker {
    id?: number; // ç¼–号
    batchId?: number; // æ‰¹æ¬¡ç¼–号
    userId?: number; // ç”Ÿäº§äººå‘˜ç”¨æˆ·ç¼–号
    userName?: string; // ç”Ÿäº§äººå‘˜æ˜µç§°
    workerType?: number; // å²—位类型(字典 mes_pro_batch_worker_type)
    remark?: string; // å¤‡æ³¨
  }
  /** ç”Ÿäº§æ‰¹æ¬¡ */
  export interface Batch {
    id?: number; // ç¼–号
    code?: string; // æ‰¹æ¬¡å·ï¼ˆè‡ªåŠ¨ç”Ÿæˆï¼‰
    itemId?: number; // å“ç§ï¼ˆäº§å“ç‰©æ–™ï¼‰ç¼–号
    itemCode?: string; // å“ç§ç¼–码(后端拼接)
    itemName?: string; // å“ç§åç§°ï¼ˆåŽç«¯æ‹¼æŽ¥ï¼‰
    itemSpecification?: string; // å“ç§è§„格(后端拼接)
    lineId?: number; // äº§çº¿ç¼–号
    lineCode?: string; // äº§çº¿ç¼–码(后端回填)
    lineName?: string; // äº§çº¿åç§°ï¼ˆåŽç«¯å›žå¡«ï¼‰
    produceDate?: string; // ç”Ÿäº§æ—¥æœŸ
    planQuantity?: number; // è®¡åˆ’数量
    ownerUserId?: number; // è´£ä»»äººç”¨æˆ·ç¼–号
    ownerUserName?: string; // è´£ä»»äººæ˜µç§°
    bagQuantity?: number; // å·²ç”Ÿæˆè¢‹ç æ•°é‡
    palletQuantity?: number; // å·²ç”Ÿæˆæ‰˜ç›˜ç æ•°é‡
    status?: number; // æ‰¹æ¬¡çŠ¶æ€ï¼ˆå­—å…¸ mes_pro_batch_status)
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
    workers?: BatchWorker[]; // ç”Ÿäº§äººå‘˜é…ç½®åˆ—表
  }
  /** ç”Ÿäº§æ‰¹æ¬¡æ‰«ç è¿½æº¯ */
  export interface BatchTrace {
    id?: number; // ç”Ÿäº§æ‰¹æ¬¡ç¼–号
    code?: string; // æ‰¹æ¬¡å·ï¼ˆäºŒç»´ç å†…容)
    status?: number; // æ‰¹æ¬¡çŠ¶æ€ï¼ˆå­—å…¸ mes_pro_batch_status)
    produceDate?: string; // ç”Ÿäº§æ—¥æœŸ
    lineCode?: string; // äº§çº¿ç¼–码
    lineName?: string; // äº§çº¿åç§°
    ownerUserId?: number; // è´£ä»»äººç”¨æˆ·ç¼–号
    ownerUserName?: string; // è´£ä»»äººæ˜µç§°
    planQuantity?: number; // è®¡åˆ’数量
    bagQuantity?: number; // å·²ç”Ÿæˆè¢‹ç æ•°é‡
    palletQuantity?: number; // å·²ç”Ÿæˆæ‰˜ç›˜ç æ•°é‡
    remark?: string; // å¤‡æ³¨
    itemId?: number; // å“ç§ï¼ˆç‰©æ–™ï¼‰ç¼–号
    itemCode?: string; // å“ç§ç¼–码
    itemName?: string; // å“ç§åç§°
    itemSpecification?: string; // å“ç§è§„æ ¼
    workers?: BatchWorker[]; // ç”Ÿäº§äººå‘˜é…ç½®åˆ—表
    pallets?: MesProPalletApi.Pallet[]; // æ‰˜ç›˜ç åˆ—表
    bags?: MesProBagCodeApi.BagCode[]; // è¢‹ç åˆ—表(最多前 2000 æ¡ï¼ŒæŒ‰è¢‹åºå·å‡åºï¼‰
    bagTotal?: number; // è¢‹ç æ€»æ•°
  }
  /** ç”Ÿäº§æ‰¹æ¬¡åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    code?: string;
    itemId?: number;
    lineId?: number;
    lineCode?: string;
    lineName?: string;
    ownerUserId?: number;
    status?: number;
    produceDate?: string[];
    createTime?: string[];
  }
}
/** æŸ¥è¯¢ç”Ÿäº§æ‰¹æ¬¡åˆ†é¡µ */
export function getBatchPage(params: MesProBatchApi.PageParams) {
  return requestClient.get<PageResult<MesProBatchApi.Batch>>(
    '/mes/pro/batch/page',
    { params },
  );
}
/** æŸ¥è¯¢ç”Ÿäº§æ‰¹æ¬¡è¯¦æƒ…(含人员配置) */
export function getBatch(id: number) {
  return requestClient.get<MesProBatchApi.Batch>(
    `/mes/pro/batch/get?id=${id}`,
  );
}
/** æ–°å¢žç”Ÿäº§æ‰¹æ¬¡ï¼ˆè‡ªåŠ¨ç”Ÿæˆæ‰¹æ¬¡å·ï¼‰ */
export function createBatch(data: MesProBatchApi.Batch) {
  return requestClient.post<number>('/mes/pro/batch/create', data);
}
/** ä¿®æ”¹ç”Ÿäº§æ‰¹æ¬¡ */
export function updateBatch(data: MesProBatchApi.Batch) {
  return requestClient.put('/mes/pro/batch/update', data);
}
/** åˆ é™¤ç”Ÿäº§æ‰¹æ¬¡ */
export function deleteBatch(id: number) {
  return requestClient.delete(`/mes/pro/batch/delete?id=${id}`);
}
/** æ›´æ–°ç”Ÿäº§æ‰¹æ¬¡çŠ¶æ€ */
export function updateBatchStatus(id: number, status: number) {
  return requestClient.put(
    `/mes/pro/batch/update-status?id=${id}&status=${status}`,
  );
}
/** æŸ¥è¯¢ç”Ÿäº§æ‰¹æ¬¡ç²¾ç®€åˆ—表(供下拉选择) */
export function getBatchSimpleList() {
  return requestClient.get<MesProBatchApi.Batch[]>('/mes/pro/batch/simple-list');
}
/** å¯¼å‡ºç”Ÿäº§æ‰¹æ¬¡ Excel */
export function exportBatchExcel(params: MesProBatchApi.PageParams) {
  return requestClient.download('/mes/pro/batch/export-excel', { params });
}
/** æ‰«ç è¿½æº¯ï¼šæ ¹æ®æ‰¹æ¬¡å·ï¼ˆäºŒç»´ç å†…容)反查批次、人员、托盘、袋码信息 */
export function getBatchByCode(code: string) {
  return requestClient.get<MesProBatchApi.BatchTrace>(
    `/mes/pro/batch/get-by-code?code=${encodeURIComponent(code)}`,
  );
}
/** æ‰¹é‡å¯¼å‡ºæ‰¹æ¬¡äºŒç»´ç å›¾ç‰‡ ZIP(批次图 + è¯¥æ‰¹æ¬¡å…¨éƒ¨æ‰˜ç›˜å›¾ï¼‰ */
export function exportBatchQr(batchId: number) {
  return requestClient.download(`/mes/pro/batch/export-qr?batchId=${batchId}`);
}
src/api/mes/pro/line/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MesProLineApi {
  /** MES äº§çº¿ */
  export interface Line {
    id?: number; // ç¼–号
    code?: string; // äº§çº¿ç¼–码
    name?: string; // äº§çº¿åç§°
    status?: number; // çŠ¶æ€ï¼ˆå­—å…¸ common_status)
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
  }
  /** äº§çº¿åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    code?: string;
    name?: string;
    status?: number;
    createTime?: string[];
  }
}
/** æŸ¥è¯¢äº§çº¿åˆ†é¡µ */
export function getLinePage(params: MesProLineApi.PageParams) {
  return requestClient.get<PageResult<MesProLineApi.Line>>(
    '/mes/pro/line/page',
    { params },
  );
}
/** æŸ¥è¯¢äº§çº¿ç²¾ç®€åˆ—表(供下拉选择,仅启用的产线) */
export function getLineSimpleList() {
  return requestClient.get<MesProLineApi.Line[]>('/mes/pro/line/simple-list');
}
/** æŸ¥è¯¢äº§çº¿è¯¦æƒ… */
export function getLine(id: number) {
  return requestClient.get<MesProLineApi.Line>(`/mes/pro/line/get?id=${id}`);
}
/** æ–°å¢žäº§çº¿ */
export function createLine(data: MesProLineApi.Line) {
  return requestClient.post('/mes/pro/line/create', data);
}
/** ä¿®æ”¹äº§çº¿ */
export function updateLine(data: MesProLineApi.Line) {
  return requestClient.put('/mes/pro/line/update', data);
}
/** åˆ é™¤äº§çº¿ */
export function deleteLine(id: number) {
  return requestClient.delete(`/mes/pro/line/delete?id=${id}`);
}
/** å¯¼å‡ºäº§çº¿ Excel */
export function exportLineExcel(params: MesProLineApi.PageParams) {
  return requestClient.download('/mes/pro/line/export-excel', { params });
}
src/api/mes/pro/pallet/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,110 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
import type { MesProBagCodeApi } from '#/api/mes/pro/bagCode';
export namespace MesProPalletApi {
  /** æ‰˜ç›˜ç  */
  export interface Pallet {
    id?: number; // ç¼–号
    code?: string; // æ‰˜ç›˜ç 
    batchId?: number; // ç”Ÿäº§æ‰¹æ¬¡ç¼–号
    batchCode?: string; // æ‰¹æ¬¡å·
    palletNo?: number; // æ‰¹æ¬¡å†…托盘序号
    capacity?: number; // å¯è£…袋数(袋/托盘)
    bagCount?: number; // å·²ç»‘定袋码数量
    status?: number; // çŠ¶æ€ï¼ˆå­—å…¸ mes_pro_pallet_status)
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
  }
  /** æ‰˜ç›˜ç åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    code?: string;
    batchId?: number;
    batchCode?: string;
    status?: number;
    createTime?: string[];
  }
  /** æ‰˜ç›˜ç æ‰«ç è¿½æº¯ */
  export interface PalletTrace {
    id?: number; // ç¼–号
    code?: string; // æ‰˜ç›˜ç ï¼ˆäºŒç»´ç å†…容)
    palletNo?: number; // æ‰¹æ¬¡å†…托盘序号
    capacity?: number; // å¯è£…袋数
    bagCount?: number; // å·²ç»‘定袋码数量
    status?: number; // çŠ¶æ€ï¼ˆå­—å…¸ mes_pro_pallet_status)
    batchId?: number; // ç”Ÿäº§æ‰¹æ¬¡ç¼–号
    batchCode?: string; // æ‰¹æ¬¡å·
    remark?: string; // å¤‡æ³¨
    batchStatus?: number; // æ‰¹æ¬¡çŠ¶æ€
    produceDate?: string; // ç”Ÿäº§æ—¥æœŸ
    lineCode?: string; // äº§çº¿ç¼–码
    lineName?: string; // äº§çº¿åç§°
    ownerUserId?: number; // è´£ä»»äººç¼–号
    ownerUserName?: string; // è´£ä»»äººæ˜µç§°
    itemId?: number; // å“ç§ï¼ˆç‰©æ–™ï¼‰ç¼–号
    itemCode?: string; // å“ç§ç¼–码
    itemName?: string; // å“ç§åç§°
    itemSpecification?: string; // å“ç§è§„æ ¼
    bags?: MesProBagCodeApi.BagCode[]; // è¯¥æ‰˜ç›˜ç»‘定的袋码列表
  }
  /** æ‰¹é‡ç”Ÿæˆè¯·æ±‚ */
  export interface BatchCreateParams {
    batchId: number; // ç”Ÿäº§æ‰¹æ¬¡ç¼–号
    count: number; // ç”Ÿæˆæ•°é‡
    capacity?: number; // å¯è£…袋数
    remark?: string; // å¤‡æ³¨
  }
}
/** æ‰¹é‡ç”Ÿæˆæ‰˜ç›˜ç ï¼ˆè¿”回生成的托盘编号列表) */
export function batchCreatePallet(data: MesProPalletApi.BatchCreateParams) {
  return requestClient.post<number[]>('/mes/pro/pallet/batch-create', data);
}
/** åˆ é™¤æ‰˜ç›˜ç  */
export function deletePallet(id: number) {
  return requestClient.delete(`/mes/pro/pallet/delete?id=${id}`);
}
/** æŸ¥è¯¢æ‰˜ç›˜ç è¯¦æƒ… */
export function getPallet(id: number) {
  return requestClient.get<MesProPalletApi.Pallet>(
    `/mes/pro/pallet/get?id=${id}`,
  );
}
/** æŸ¥è¯¢æ‰˜ç›˜ç åˆ†é¡µ */
export function getPalletPage(params: MesProPalletApi.PageParams) {
  return requestClient.get<PageResult<MesProPalletApi.Pallet>>(
    '/mes/pro/pallet/page',
    { params },
  );
}
/** æŸ¥è¯¢æ‰¹æ¬¡çš„æ‰˜ç›˜ç åˆ—表(供下拉选择) */
export function getPalletSimpleList(batchId: number) {
  return requestClient.get<MesProPalletApi.Pallet[]>(
    `/mes/pro/pallet/simple-list?batchId=${batchId}`,
  );
}
/** å¯¼å‡ºæ‰˜ç›˜ç  Excel */
export function exportPalletExcel(params: MesProPalletApi.PageParams) {
  return requestClient.download('/mes/pro/pallet/export-excel', { params });
}
/** æ‰«ç è¿½æº¯ï¼šæ ¹æ®æ‰˜ç›˜ç ï¼ˆäºŒç»´ç å†…容)反查批次、品种、责任人及袋码列表 */
export function getPalletByCode(code: string) {
  return requestClient.get<MesProPalletApi.PalletTrace>(
    `/mes/pro/pallet/get-by-code?code=${encodeURIComponent(code)}`,
  );
}
/** æ‰¹é‡å¯¼å‡ºæŸæ‰¹æ¬¡çš„æ‰˜ç›˜ç äºŒç»´ç å›¾ç‰‡ ZIP */
export function exportPalletQr(batchId: number) {
  return requestClient.download(`/mes/pro/pallet/export-qr?batchId=${batchId}`);
}
src/components/table-action/table-action.vue
@@ -15,6 +15,7 @@
  Button,
  Dropdown,
  Menu,
  Modal,
  Popconfirm,
  Space,
  Tooltip,
@@ -122,7 +123,23 @@
/** å¤„理菜单点击 */
function handleMenuClick(e: any) {
  const action = getDropdownList.value[e.key];
  if (action && action.onClick && isFunction(action.onClick)) {
  if (!action) {
    return;
  }
  const popConfirm = action.popConfirm;
  if (popConfirm) {
    // ä¸‹æ‹‰èœå•项会被 Dropdown è‡ªåŠ¨å…³é—­é”€æ¯ï¼ŒåµŒå¥— Popconfirm æ— æ³•弹出,
    // æ•…改用命令式 Modal.confirm å®Œæˆç¡®è®¤
    Modal.confirm({
      title: popConfirm.title,
      okText: popConfirm.okText,
      cancelText: popConfirm.cancelText,
      onOk: popConfirm.confirm ? () => popConfirm.confirm!() : undefined,
      onCancel: popConfirm.cancel ? () => popConfirm.cancel!() : undefined,
    });
    return;
  }
  if (action.onClick && isFunction(action.onClick)) {
    action.onClick();
  }
}
@@ -201,39 +218,20 @@
            v-for="(action, index) in getDropdownList"
            :key="index"
            :disabled="action.disabled"
            @click="!action.popConfirm && handleMenuClick({ key: index })"
            @click="handleMenuClick({ key: index })"
          >
            <template v-if="action.popConfirm">
              <Popconfirm v-bind="getPopConfirmProps(action.popConfirm)">
                <template v-if="action.popConfirm.icon" #icon>
                  <IconifyIcon :icon="action.popConfirm.icon" />
                </template>
                <div
                  :class="
                    action.disabled === true
                      ? 'cursor-not-allowed text-gray-300'
                      : ''
                  "
                >
                  <IconifyIcon v-if="action.icon" :icon="action.icon" />
                  <span :class="action.icon ? 'ml-1' : ''">
                    {{ action.label }}
                  </span>
                </div>
              </Popconfirm>
            </template>
            <template v-else>
              <div
                :class="
                  action.disabled === true
                    ? 'cursor-not-allowed text-gray-300'
                    : ''
                "
              >
                <IconifyIcon v-if="action.icon" :icon="action.icon" />
            <div
              :class="
                action.disabled === true
                  ? 'cursor-not-allowed text-gray-300'
                  : ''
              "
            >
              <IconifyIcon v-if="action.icon" :icon="action.icon" />
              <span :class="action.icon ? 'ml-1' : ''">
                {{ action.label }}
              </div>
            </template>
              </span>
            </div>
          </Menu.Item>
        </Menu>
      </template>
src/main.ts
@@ -35,6 +35,10 @@
      copyright: { companyName: __APP_NAME__ },
    });
  }
  // å¼ºåˆ¶è¦†ç›–默认首页为"基础数据与一袋一码",绕过 localStorage ç¼“å­˜
  preferencesManager.updatePreferences({
    app: { defaultHomePath: '/bagcode-home' },
  });
  if (__APP_FAVICON__) {
    const link = document.querySelector('link[rel="icon"]');
    if (link) {
src/packages/@core/preferences/src/config.ts
@@ -17,7 +17,7 @@
    contentPaddingTop: 0,
    defaultAvatar:
      'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp',
    defaultHomePath: '/analytics',
    defaultHomePath: '/bagcode-home',
    dynamicTitle: true,
    enableCheckUpdates: true,
    enableCopyPreferences: true,
src/packages/@core/ui-kit/layout-ui/src/vben-layout.vue
@@ -495,7 +495,7 @@
</script>
<template>
  <div class="relative flex min-h-full w-full">
  <div class="relative flex h-full w-full">
    <LayoutSidebar
      v-if="sidebarEnableState"
      v-model:draggable="sidebarDraggable"
src/packages/constants/src/biz-mes-enum.ts
@@ -267,6 +267,36 @@
  CANCELLED: MesOrderStatusConstants.CANCELLED,
} as const;
/** MES ç”Ÿäº§æ‰¹æ¬¡çŠ¶æ€æžšä¸¾ï¼ˆå¯¹é½åŽç«¯ MesProBatchStatusEnum) */
export const MesProBatchStatusEnum = {
  DRAFT: 10, // è‰ç¨¿
  PRODUCING: 20, // ç”Ÿäº§ä¸­
  FINISHED: 30, // å·²å®Œæˆ
  CANCELED: 40, // å·²ä½œåºŸ
} as const;
/** MES ç”Ÿäº§æ‰¹æ¬¡äººå‘˜å²—位类型枚举(对齐后端 MesProBatchWorkerTypeEnum) */
export const MesProBatchWorkerTypeEnum = {
  LEADER: 10, // ç»„é•¿
  OPERATOR: 20, // æ“ä½œå·¥
  PACKER: 30, // åŒ…装工
  INSPECTOR: 40, // è´¨æ£€å‘˜
  OTHER: 50, // å…¶ä»–
} as const;
/** MES è¢‹ç çŠ¶æ€æžšä¸¾ï¼ˆå¯¹é½åŽç«¯ MesProBagCodeStatusEnum) */
export const MesProBagCodeStatusEnum = {
  GENERATED: 10, // å·²ç”Ÿæˆ
  PRINTED: 20, // å·²å¯¼å‡ºå°åˆ·
  CANCELED: 30, // å·²ä½œåºŸ
} as const;
/** MES æ‰˜ç›˜ç çŠ¶æ€æžšä¸¾ï¼ˆå¯¹é½åŽç«¯ MesProPalletStatusEnum) */
export const MesProPalletStatusEnum = {
  IN_USE: 10, // å¯ç”¨
  CANCELED: 20, // ä½œåºŸ
} as const;
/** MES ç”Ÿäº§æŠ¥å·¥çŠ¶æ€æžšä¸¾ */
export const MesProFeedbackStatusEnum = {
  PREPARE: MesOrderStatusConstants.DRAFT,
src/packages/constants/src/dict-enum.ts
@@ -243,6 +243,10 @@
  MES_PRO_WORK_ORDER_TYPE: 'mes_pro_work_order_type', // MES å·¥å•类型
  MES_PRO_WORK_ORDER_SOURCE_TYPE: 'mes_pro_work_order_source_type', // MES å·¥å•来源类型
  MES_PRO_TASK_STATUS: 'mes_pro_task_status', // MES ç”Ÿäº§ä»»åŠ¡çŠ¶æ€
  MES_PRO_BATCH_STATUS: 'mes_pro_batch_status', // MES ç”Ÿäº§æ‰¹æ¬¡çŠ¶æ€
  MES_PRO_BATCH_WORKER_TYPE: 'mes_pro_batch_worker_type', // MES ç”Ÿäº§æ‰¹æ¬¡äººå‘˜å²—位类型
  MES_PRO_BAG_CODE_STATUS: 'mes_pro_bag_code_status', // MES è¢‹ç çŠ¶æ€
  MES_PRO_PALLET_STATUS: 'mes_pro_pallet_status', // MES æ‰˜ç›˜ç çŠ¶æ€
  MES_PRO_FEEDBACK_STATUS: 'mes_pro_feedback_status', // MES ç”Ÿäº§æŠ¥å·¥çŠ¶æ€
  MES_PRO_FEEDBACK_TYPE: 'mes_pro_feedback_type', // MES ç”Ÿäº§æŠ¥å·¥ç±»åž‹
  MES_PD_PROJECT_STATUS: 'mes_pd_project_status', // MES äº§å“è®¾è®¡é¡¹ç›®çŠ¶æ€
src/router/routes/modules/bagcode-home.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
  {
    name: 'BagCodeHome',
    path: '/bagcode-home',
    component: () => import('#/views/dashboard/bagCodeHome/index.vue'),
    meta: {
      icon: 'lucide:scan-barcode',
      order: 1,
      title: '首页',
    },
  },
];
export default routes;
src/router/routes/modules/dashboard.ts
@@ -1,17 +1,6 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
  {
    name: 'Analytics',
    path: '/analytics',
    component: () => import('#/views/dashboard/analytics/index.vue'),
    meta: {
      affixTab: true,
      icon: 'lucide:area-chart',
      order: -1,
      title: '首页',
    },
  },
];
// è€é¦–页 /analytics å·²éšè—ï¼Œç™»å½•后默认跳转 /bagcode-home
const routes: RouteRecordRaw[] = [];
export default routes;
src/views/basicData/mdm/components/data.ts
@@ -12,28 +12,28 @@
  return [
    {
      fieldName: 'code',
      label: '物料编码',
      label: '品种编码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入物料编码',
        placeholder: '请输入品种编码',
      },
    },
    {
      fieldName: 'name',
      label: '物料名称',
      label: '品种名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入物料名称',
        placeholder: '请输入品种名称',
      },
    },
    {
      fieldName: 'categoryId',
      label: '物料分类',
      label: '品种分类',
      component: 'ApiSelect',
      componentProps: {
        placeholder: '请选择物料分类',
        placeholder: '请选择品种分类',
        allowClear: true,
        api: async () => {
          const res = await getItemTypeSimpleList();
@@ -64,12 +64,12 @@
    { type: multiple ? 'checkbox' : 'radio', width: 50 },
    {
      field: 'code',
      title: '物料编码',
      title: '品种编码',
      minWidth: 120,
    },
    {
      field: 'name',
      title: '物料名称',
      title: '品种名称',
      minWidth: 180,
      align: 'left',
    },
@@ -81,7 +81,7 @@
    },
    {
      field: 'categoryId',
      title: '物料分类',
      title: '品种分类',
      minWidth: 120,
      align: 'center',
      slots: { default: 'categoryId' },
src/views/basicData/mdm/components/select-dialog.vue
@@ -209,7 +209,7 @@
function handleConfirm() {
  const rows = multiple.value ? getMultipleSelectedRows() : selectedRows.value;
  if (rows.length === 0) {
    message.warning(multiple.value ? '请至少选择一条数据' : '请选择一条数据');
    message.warning(multiple.value ? '请至少选择一个品种' : '请选择一个品种');
    return;
  }
  emit('selected', multiple.value ? rows : [rows[0]!]);
@@ -222,13 +222,13 @@
<template>
  <Modal
    v-model:open="open"
    title="物料选择"
    title="品种选择"
    width="80%"
    :destroy-on-close="true"
    @ok="handleConfirm"
    @cancel="closeModal"
  >
    <Grid table-title="物料列表">
    <Grid table-title="品种列表">
      <template #categoryId="{ row }">
        <Tag v-if="getCategoryColor(row.categoryId)" :color="getCategoryColor(row.categoryId)">{{ getCategoryName(row.categoryId) }}</Tag>
        <span v-else>{{ getCategoryName(row.categoryId) || '-' }}</span>
src/views/basicData/mdm/components/select.vue
@@ -25,7 +25,7 @@
    allowClear: true,
    disabled: false,
    modelValue: undefined,
    placeholder: '请选择物料',
    placeholder: '请选择品种',
    label: undefined,
  },
);
src/views/basicData/mdm/data.ts
@@ -95,12 +95,12 @@
    {
      component: "Input",
      fieldName: "code",
      label: "物料编码",
      label: "品种编码",
      componentProps: {
        maxLength: 64,
        placeholder: "请输入物料编码",
        placeholder: "请输入品种编码",
      },
      rules: z.string().min(1, '物料编码不能为空').max(64),
      rules: z.string().min(1, '品种编码不能为空').max(64),
      suffix: () =>
        h(
          Button,
@@ -119,19 +119,19 @@
    {
      component: "Input",
      fieldName: "name",
      label: "物料名称",
      label: "品种名称",
      componentProps: {
        placeholder: "请输入物料名称",
        placeholder: "请输入品种名称",
      },
      rules: "required",
    },
    {
      component: "TreeSelect",
      fieldName: "categoryId",
      label: "物料分类",
      label: "品种分类",
      rules: "selectRequired",
      componentProps: {
        placeholder: "请选择物料分类",
        placeholder: "请选择品种分类",
        allowClear: true,
        treeDefaultExpandAll: true,
        treeData: categoryTreeData,
@@ -492,19 +492,19 @@
  return [
    {
      fieldName: "code",
      label: "物料编码",
      label: "品种编码",
      component: "Input",
      componentProps: {
        placeholder: "请输入物料编码",
        placeholder: "请输入品种编码",
        allowClear: true,
      },
    },
    {
      fieldName: "name",
      label: "物料名称",
      label: "品种名称",
      component: "Input",
      componentProps: {
        placeholder: "请输入物料名称",
        placeholder: "请输入品种名称",
        allowClear: true,
      },
    },
@@ -527,12 +527,12 @@
    { type: "checkbox", width: 40 },
    {
      field: "code",
      title: "物料编码",
      title: "品种编码",
      minWidth: 120,
    },
    {
      field: "name",
      title: "物料名称",
      title: "品种名称",
      minWidth: 180,
    },
    {
@@ -542,7 +542,7 @@
    },
    {
      field: "categoryName",
      title: "物料分类",
      title: "品种分类",
      minWidth: 120,
      slots: { default: "categoryName" },
    },
src/views/basicData/mdm/index.vue
@@ -78,20 +78,20 @@
/** å¯¼å‡ºè¡¨æ ¼ */
// async function handleExport() {
//   const data = await exportItem(await gridApi.formApi.getValues());
//   downloadFileFromBlobPart({ fileName: '物料.xls', source: data });
//   downloadFileFromBlobPart({ fileName: '品种.xls', source: data });
// }
/** åˆ›å»ºç‰©æ–™ */
/** åˆ›å»ºå“ç§ */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** ç¼–辑物料 */
/** ç¼–辑品种 */
function handleEdit(row: MdmItemApi.Item) {
  formModalApi.setData(row).open();
}
/** åˆ é™¤ç‰©æ–™ */
/** åˆ é™¤å“ç§ */
async function handleDelete(row: MdmItemApi.Item) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.name]),
@@ -106,7 +106,7 @@
  }
}
/** æ‰¹é‡åˆ é™¤ç‰©æ–™ */
/** æ‰¹é‡åˆ é™¤å“ç§ */
async function handleDeleteBatch() {
  await confirm($t('ui.actionMessage.deleteBatchConfirm'));
  const hideLoading = message.loading({
@@ -141,10 +141,10 @@
const syncingId = ref<number | null>(null);
/** åˆ‡æ¢åŒæ­¥çŠ¶æ€ï¼šå…³é—­å¼€å…³ä¼šä»Ž MES åˆ é™¤ç‰©æ–™ï¼Œéœ€ç¡®è®¤ */
/** åˆ‡æ¢åŒæ­¥çŠ¶æ€ï¼šå…³é—­å¼€å…³ä¼šä»Ž MES åˆ é™¤å“ç§ï¼Œéœ€ç¡®è®¤ */
async function handleSyncChange(row: MdmItemApi.Item, checked: boolean) {
  if (!checked) {
    await confirm('关闭同步将从 MES åˆ é™¤è¯¥ç‰©æ–™åŠå…³è”配置(BOM/SOP/SIP/批次配置),确认继续?');
    await confirm('关闭同步将从 MES åˆ é™¤è¯¥å“ç§åŠå…³è”配置(BOM/SOP/SIP/批次配置),确认继续?');
  }
  syncingId.value = row.id!;
  try {
@@ -211,12 +211,12 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="物料列表">
    <Grid table-title="品种列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['物料']),
              label: $t('ui.actionTitle.create', ['品种']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mdm:item:create'],
src/views/basicData/mdm/modules/form.vue
@@ -83,7 +83,7 @@
</script>
<template>
  <Modal class="w-[1000px]" :title="formData?.id ? $t('ui.actionTitle.edit', ['物料']) : $t('ui.actionTitle.create', ['物料'])">
  <Modal class="w-[1000px]" :title="formData?.id ? $t('ui.actionTitle.edit', ['品种']) : $t('ui.actionTitle.create', ['品种'])">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/dashboard/bagCodeHome/chart-options.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
import type { EChartsOption } from '@vben/plugins/echarts';
/** è¢‹ç ç”Ÿæˆè¶‹åŠ¿æŸ±çŠ¶å›¾é…ç½® */
export function getBagCodeTrendChartOptions(
  dates: string[],
  counts: number[],
): EChartsOption {
  return {
    grid: { bottom: 40, left: 50, right: 20, top: 20 },
    legend: { bottom: 0, data: ['袋码生成量'] },
    series: [
      {
        barMaxWidth: 32,
        data: counts,
        itemStyle: { borderRadius: 4, color: '#409EFF' },
        name: '袋码生成量',
        type: 'bar',
      },
    ],
    tooltip: { axisPointer: { type: 'shadow' }, trigger: 'axis' },
    xAxis: { axisTick: { show: false }, data: dates, type: 'category' },
    yAxis: { minInterval: 1, type: 'value' },
  };
}
/** çŠ¶æ€åˆ†å¸ƒçŽ¯å½¢å›¾é…ç½® */
export function getStatusChartOptions(
  data: Array<{ itemStyle: { color: string }; name: string; value: number }>,
): EChartsOption {
  return {
    legend: { bottom: 0, type: 'scroll' },
    series: [
      {
        avoidLabelOverlap: true,
        data,
        emphasis: { label: { fontSize: 14, fontWeight: 'bold', show: true } },
        itemStyle: { borderColor: '#fff', borderRadius: 6, borderWidth: 2 },
        label: { formatter: '{b}\n{c}', show: true },
        radius: ['40%', '70%'],
        type: 'pie',
      },
    ],
    tooltip: { formatter: '{b}: {c} ({d}%)', trigger: 'item' },
  };
}
src/views/dashboard/bagCodeHome/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home';
/** é¦–页汇总统计默认值 */
export const defaultSummary: MesBagCodeHomeApi.Summary = {
  bagCodeCount: 0,
  bagCodeStatusList: [],
  batchCount: 0,
  batchStatusList: [],
  itemTypeCount: 0,
  lineCount: 0,
  mdmItemCount: 0,
  mesItemCount: 0,
  palletCount: 0,
  unitMeasureCount: 0,
};
/** æ‰¹æ¬¡çŠ¶æ€å¯¹åº”çš„é¢œè‰²æ˜ å°„ï¼ˆçŠ¶æ€å­—å…¸ï¼š10 è‰ç¨¿ 20 ç”Ÿäº§ä¸­ 30 å·²å®Œæˆ 40 å·²ä½œåºŸï¼‰ */
export const BATCH_STATUS_COLOR_MAP: Record<number, string> = {
  10: '#909399', // è‰ç¨¿
  20: '#409EFF', // ç”Ÿäº§ä¸­
  30: '#67C23A', // å·²å®Œæˆ
  40: '#F56C6C', // å·²ä½œåºŸ
};
/** è¢‹ç çŠ¶æ€å¯¹åº”çš„é¢œè‰²æ˜ å°„ï¼ˆçŠ¶æ€å­—å…¸ï¼š10 å·²ç”Ÿæˆ 20 å·²å¯¼å‡ºå°åˆ· 30 å·²ä½œåºŸï¼‰ */
export const BAG_CODE_STATUS_COLOR_MAP: Record<number, string> = {
  10: '#409EFF', // å·²ç”Ÿæˆ
  20: '#E6A23C', // å·²å¯¼å‡ºå°åˆ·
  30: '#F56C6C', // å·²ä½œåºŸ
};
src/views/dashboard/bagCodeHome/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
<script lang="ts" setup>
import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Col, Row, message } from 'ant-design-vue';
import { getBagCodeHomeSummary } from '#/api/mes/bag-code-home';
import { defaultSummary } from './data';
import BagCodeCards from './modules/bag-code-cards.vue';
import BagCodeStatusChart from './modules/bag-code-status-chart.vue';
import BasicCards from './modules/basic-cards.vue';
import BatchStatusChart from './modules/batch-status-chart.vue';
import Shortcuts from './modules/shortcuts.vue';
import TrendChart from './modules/trend-chart.vue';
defineOptions({ name: 'DashboardBagCodeHome' });
const router = useRouter();
const summary = ref<MesBagCodeHomeApi.Summary>(defaultSummary); // é¦–页汇总统计
/** è·³è½¬åˆ°ç›®æ ‡é¡µé¢ï¼ˆæŒ‰è·¯ç”± name,未加载时提示) */
async function handleNavigate(name: string) {
  try {
    await router.push({ name });
  } catch {
    message.warning('当前菜单尚未加载,请从左侧菜单进入对应页面');
  }
}
/** åŠ è½½é¦–é¡µæ±‡æ€»ç»Ÿè®¡ */
async function loadSummary() {
  summary.value = await getBagCodeHomeSummary();
}
onMounted(() => {
  loadSummary();
});
</script>
<template>
  <Page>
    <!-- ç¬¬ä¸€è¡Œï¼šåŸºç¡€æ•°æ®å¡ç‰‡ -->
    <BasicCards :summary="summary" class="mb-2" @navigate="handleNavigate" />
    <!-- ç¬¬äºŒè¡Œï¼šä¸€è¢‹ä¸€ç å¡ç‰‡ -->
    <BagCodeCards :summary="summary" class="mb-4" @navigate="handleNavigate" />
    <!-- ç¬¬ä¸‰è¡Œï¼šè¢‹ç è¶‹åŠ¿ + æ‰¹æ¬¡/袋码状态分布 -->
    <Row :gutter="16" class="mb-4">
      <Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4">
        <TrendChart />
      </Col>
      <Col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" class="mb-4">
        <BatchStatusChart :list="summary.batchStatusList" />
      </Col>
      <Col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" class="mb-4">
        <BagCodeStatusChart :list="summary.bagCodeStatusList" />
      </Col>
    </Row>
    <!-- ç¬¬å››è¡Œï¼šå¿«æ·å…¥å£ -->
    <Shortcuts @navigate="handleNavigate" />
  </Page>
</template>
src/views/dashboard/bagCodeHome/modules/bag-code-cards.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
<script lang="ts" setup>
import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Card, Col, Row } from 'ant-design-vue';
defineOptions({ name: 'BagCodeHomeBagCodeCards' });
const props = defineProps<{
  summary: MesBagCodeHomeApi.Summary;
}>();
const emit = defineEmits<{
  navigate: [name: string];
}>();
/** ä¸€è¢‹ä¸€ç å¡ç‰‡é…ç½® */
const cards = [
  {
    gradient: 'linear-gradient(135deg, #0ea5e9, #38bdf8)',
    icon: 'lucide:factory',
    name: '产线',
    routeName: 'MesProLine',
    value: () => props.summary.lineCount,
  },
  {
    gradient: 'linear-gradient(135deg, #f59e0b, #fbbf24)',
    icon: 'lucide:layers',
    name: '生产批次',
    routeName: 'MesProBatch',
    value: () => props.summary.batchCount,
  },
  {
    gradient: 'linear-gradient(135deg, #ec4899, #f472b6)',
    icon: 'lucide:scan-barcode',
    name: '袋码',
    routeName: 'MesProBagCode',
    value: () => props.summary.bagCodeCount,
  },
  {
    gradient: 'linear-gradient(135deg, #14b8a6, #2dd4bf)',
    icon: 'lucide:grid-2x2',
    name: '托盘码',
    routeName: 'MesProPallet',
    value: () => props.summary.palletCount,
  },
];
</script>
<template>
  <Row :gutter="16">
    <Col v-for="card in cards" :key="card.name" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <Card
        class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        @click="emit('navigate', card.routeName)"
      >
        <div class="flex items-center gap-4">
          <div
            class="flex size-14 flex-shrink-0 items-center justify-center rounded-xl text-white"
            :style="{ background: card.gradient }"
          >
            <IconifyIcon class="size-7" :icon="card.icon" />
          </div>
          <div>
            <div class="text-muted-foreground mb-1 text-sm">{{ card.name }}</div>
            <CountTo class="text-2xl font-bold leading-tight" :end-val="card.value()" :duration="1500" />
          </div>
        </div>
      </Card>
    </Col>
  </Row>
</template>
src/views/dashboard/bagCodeHome/modules/bag-code-status-chart.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home';
import { ref, watch } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Card } from 'ant-design-vue';
import { getStatusChartOptions } from '../chart-options';
import { BAG_CODE_STATUS_COLOR_MAP } from '../data';
defineOptions({ name: 'BagCodeHomeBagCodeStatusChart' });
const props = defineProps<{
  list: MesBagCodeHomeApi.StatusItem[];
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
watch(
  () => props.list,
  async (list) => {
    const data = list.map((item) => ({
      itemStyle: { color: BAG_CODE_STATUS_COLOR_MAP[item.status] || '#909399' },
      name: getDictLabel(DICT_TYPE.MES_PRO_BAG_CODE_STATUS, item.status) || `状态${item.status}`,
      value: item.count,
    }));
    await renderEcharts(getStatusChartOptions(data));
  },
  { immediate: true },
);
</script>
<template>
  <Card title="袋码状态分布" class="h-full">
    <EchartsUI ref="chartRef" class="h-[320px] w-full" />
  </Card>
</template>
src/views/dashboard/bagCodeHome/modules/basic-cards.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
<script lang="ts" setup>
import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Card, Col, Row } from 'ant-design-vue';
defineOptions({ name: 'BagCodeHomeBasicCards' });
const props = defineProps<{
  summary: MesBagCodeHomeApi.Summary;
}>();
const emit = defineEmits<{
  navigate: [name: string];
}>();
/** åŸºç¡€æ•°æ®å¡ç‰‡é…ç½® */
const cards = [
  {
    gradient: 'linear-gradient(135deg, #409eff, #66b1ff)',
    icon: 'lucide:package',
    name: '品种总数',
    routeName: 'MdmItem',
    value: () => props.summary.mdmItemCount,
  },
  {
    gradient: 'linear-gradient(135deg, #67c23a, #85ce61)',
    icon: 'lucide:box',
    name: 'MES å“ç§',
    routeName: 'MesMdItem',
    value: () => props.summary.mesItemCount,
  },
  {
    gradient: 'linear-gradient(135deg, #e6a23c, #ebb563)',
    icon: 'lucide:folder-tree',
    name: '品种分类',
    routeName: 'MesMdItemType',
    value: () => props.summary.itemTypeCount,
  },
  {
    gradient: 'linear-gradient(135deg, #7c3aed, #9461f5)',
    icon: 'lucide:ruler',
    name: '计量单位',
    routeName: 'MdmUnit',
    value: () => props.summary.unitMeasureCount,
  },
];
</script>
<template>
  <Row :gutter="16">
    <Col v-for="card in cards" :key="card.name" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <Card
        class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg"
        @click="emit('navigate', card.routeName)"
      >
        <div class="flex items-center gap-4">
          <div
            class="flex size-14 flex-shrink-0 items-center justify-center rounded-xl text-white"
            :style="{ background: card.gradient }"
          >
            <IconifyIcon class="size-7" :icon="card.icon" />
          </div>
          <div>
            <div class="text-muted-foreground mb-1 text-sm">{{ card.name }}</div>
            <CountTo class="text-2xl font-bold leading-tight" :end-val="card.value()" :duration="1500" />
          </div>
        </div>
      </Card>
    </Col>
  </Row>
</template>
src/views/dashboard/bagCodeHome/modules/batch-status-chart.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home';
import { ref, watch } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Card } from 'ant-design-vue';
import { getStatusChartOptions } from '../chart-options';
import { BATCH_STATUS_COLOR_MAP } from '../data';
defineOptions({ name: 'BagCodeHomeBatchStatusChart' });
const props = defineProps<{
  list: MesBagCodeHomeApi.StatusItem[];
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
watch(
  () => props.list,
  async (list) => {
    const data = list.map((item) => ({
      itemStyle: { color: BATCH_STATUS_COLOR_MAP[item.status] || '#909399' },
      name: getDictLabel(DICT_TYPE.MES_PRO_BATCH_STATUS, item.status) || `状态${item.status}`,
      value: item.count,
    }));
    await renderEcharts(getStatusChartOptions(data));
  },
  { immediate: true },
);
</script>
<template>
  <Card title="批次状态分布" class="h-full">
    <EchartsUI ref="chartRef" class="h-[320px] w-full" />
  </Card>
</template>
src/views/dashboard/bagCodeHome/modules/shortcuts.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import { IconifyIcon } from '@vben/icons';
import { Card, Col, Row, message } from 'ant-design-vue';
defineOptions({ name: 'BagCodeHomeShortcuts' });
const router = useRouter();
/** å¿«æ·å…¥å£åˆ—表,按业务域精简为 4 ä¸ªæ ¸å¿ƒå…¥å£ */
const shortcuts = [
  { bgColor: '#409EFF', icon: 'lucide:package', name: '品种管理', routeName: 'MdmItem' },
  { bgColor: '#E6A23C', icon: 'lucide:folder-tree', name: '品种分类', routeName: 'MesMdItemType' },
  { bgColor: '#f59e0b', icon: 'lucide:layers', name: '批次生产', routeName: 'MesProBatch' },
  { bgColor: '#ec4899', icon: 'lucide:scan-barcode', name: '赋码管理', routeName: 'MesProBagCode' },
];
/** è·³è½¬åˆ°ç›®æ ‡é¡µé¢ï¼ˆæŒ‰è·¯ç”± name,未加载时提示) */
async function handleNavigate(routeName: string) {
  try {
    await router.push({ name: routeName });
  } catch {
    message.warning('当前菜单尚未加载,请从左侧菜单进入对应页面');
  }
}
</script>
<template>
  <Card title="快捷入口" class="h-full">
    <Row :gutter="16">
      <Col v-for="item in shortcuts" :key="item.name" :lg="6" :md="8" :sm="12" :xs="12" class="mb-4">
        <div
          class="hover:bg-accent flex cursor-pointer flex-col items-center gap-2 rounded-lg py-3 transition-all hover:-translate-y-0.5"
          @click="handleNavigate(item.routeName)"
        >
          <div
            class="flex size-12 items-center justify-center rounded-xl text-white"
            :style="{ background: item.bgColor }"
          >
            <IconifyIcon class="size-6" :icon="item.icon" />
          </div>
          <span class="text-sm">{{ item.name }}</span>
        </div>
      </Col>
    </Row>
  </Card>
</template>
src/views/dashboard/bagCodeHome/modules/trend-chart.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Card, RadioButton, RadioGroup } from 'ant-design-vue';
import { getBagCodeHomeTrend } from '#/api/mes/bag-code-home';
import { getBagCodeTrendChartOptions } from '../chart-options';
defineOptions({ name: 'BagCodeHomeTrendChart' });
const trendDays = ref(7); // å½“前选中的天数范围
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** åŠ è½½è¢‹ç ç”Ÿæˆè¶‹åŠ¿æ•°æ®å¹¶æ¸²æŸ“å›¾è¡¨ */
async function loadData() {
  const data = await getBagCodeHomeTrend(trendDays.value);
  const dates = data.map((d) => d.date.slice(5));
  const counts = data.map((d) => d.count);
  await renderEcharts(getBagCodeTrendChartOptions(dates, counts));
}
/** åˆ‡æ¢å¤©æ•°èŒƒå›´ */
function handleDaysChange() {
  loadData();
}
onMounted(() => {
  loadData();
});
</script>
<template>
  <Card title="袋码生成趋势" class="h-full">
    <template #extra>
      <RadioGroup v-model:value="trendDays" size="small" @change="handleDaysChange">
        <RadioButton :value="7">近 7 å¤©</RadioButton>
        <RadioButton :value="30">近 30 å¤©</RadioButton>
      </RadioGroup>
    </template>
    <EchartsUI ref="chartRef" class="h-[320px] w-full" />
  </Card>
</template>
src/views/mes/md/item/components/select-dialog.vue
@@ -23,11 +23,11 @@
  const open = ref(false); // å¼¹çª—是否打开
  const multiple = ref(true); // æ˜¯å¦å¤šé€‰
  const selectedRows = ref<MesMdItemApi.Item[]>([]); // å·²é€‰ç‰©æ–™åˆ—表
  const selectedRows = ref<MesMdItemApi.Item[]>([]); // å·²é€‰å“ç§åˆ—表
  const selectedItemTypeId = ref<number>(); // å½“前筛选分类编号
  const preSelectedIds = ref<number[]>([]); // é¢„选物料编号列表
  const productIds = ref<number[]>([]); // é™å®šå¯é€‰çš„物料编号列表(为空则不限制)
  const typeTreeRef = ref<InstanceType<typeof MdItemTypeTree>>(); // ç‰©æ–™åˆ†ç±»æ ‘
  const preSelectedIds = ref<number[]>([]); // é¢„选品种编号列表
  const productIds = ref<number[]>([]); // é™å®šå¯é€‰çš„品种编号列表(为空则不限制)
  const typeTreeRef = ref<InstanceType<typeof MdItemTypeTree>>(); // å“ç§åˆ†ç±»æ ‘
  /** èŽ·å–å½“å‰è¡¨æ ¼æ•°æ® */
  function getTableRows() {
@@ -78,13 +78,13 @@
    handleConfirm();
  }
  /** æŒ‰åˆ†ç±»ç­›é€‰ç‰©æ–™ */
  /** æŒ‰åˆ†ç±»ç­›é€‰å“ç§ */
  function handleItemTypeNodeClick(row: MesMdItemTypeApi.ItemType | undefined) {
    selectedItemTypeId.value = row?.id;
    gridApi.query();
  }
  /** å›žæ˜¾é¢„选物料 */
  /** å›žæ˜¾é¢„选品种 */
  async function applyPreSelection() {
    if (preSelectedIds.value.length === 0) {
      return;
@@ -138,7 +138,7 @@
              itemTypeId: selectedItemTypeId.value,
              status: CommonStatusEnum.ENABLE,
            };
            // é™å®šå¯é€‰ç‰©æ–™èŒƒå›´
            // é™å®šå¯é€‰å“ç§èŒƒå›´
            if (productIds.value.length > 0) {
              params.pageSize = 1000;
              params.ids = productIds.value.join(',');
@@ -178,7 +178,7 @@
    await gridApi.formApi.resetForm();
  }
  /** æ‰“开物料选择弹窗 */
  /** æ‰“开品种选择弹窗 */
  async function openModal(
    selectedIds?: number[],
    options?: { multiple?: boolean; productIds?: number[] }
@@ -197,13 +197,13 @@
    await applyPreSelection();
  }
  /** å…³é—­ç‰©æ–™é€‰æ‹©å¼¹çª— */
  /** å…³é—­å“ç§é€‰æ‹©å¼¹çª— */
  async function closeModal() {
    open.value = false;
    await resetQueryState();
  }
  /** ç¡®è®¤é€‰æ‹©ç‰©æ–™ */
  /** ç¡®è®¤é€‰æ‹©å“ç§ */
  function handleConfirm() {
    const rows = multiple.value ? getMultipleSelectedRows() : selectedRows.value;
    if (rows.length === 0) {
@@ -219,7 +219,7 @@
<template>
  <Modal v-model:open="open"
         title="物料产品选择"
         title="品种产品选择"
         width="80%"
         :destroy-on-close="true"
         @ok="handleConfirm"
@@ -230,7 +230,7 @@
                        @node-click="handleItemTypeNodeClick" />
      </Card>
      <div class="w-4/5">
        <Grid table-title="物料产品列表" />
        <Grid table-title="品种产品列表" />
      </div>
    </div>
  </Modal>
src/views/mes/md/item/components/select.vue
@@ -27,7 +27,7 @@
    disabled: false,
    modelValue: undefined,
    multiple: false,
    placeholder: '请选择产品物料',
    placeholder: '请选择产品品种',
    productIds: () => [],
  },
);
@@ -54,7 +54,7 @@
      : props.modelValue != null),
);
/** æ ¹æ®ç‰©æ–™ç¼–号回显选择器 */
/** æ ¹æ®å“ç§ç¼–号回显选择器 */
async function resolveItemsById(values: number | number[] | undefined) {
  if (values == null || (Array.isArray(values) && values.length === 0)) {
    selectedItems.value = [];
@@ -80,7 +80,7 @@
  { immediate: true },
);
/** æ¸…空已选物料 */
/** æ¸…空已选品种 */
function clearSelected() {
  selectedItems.value = [];
  if (props.multiple) {
@@ -92,7 +92,7 @@
  }
}
/** æ‰“开物料选择弹窗 */
/** æ‰“开品种选择弹窗 */
function handleClick(event: MouseEvent) {
  if (props.disabled) {
    return;
@@ -114,7 +114,7 @@
  });
}
/** å›žå¡«é€‰ä¸­çš„物料 */
/** å›žå¡«é€‰ä¸­çš„品种 */
function handleSelected(rows: MesMdItemApi.Item[]) {
  if (props.multiple) {
    selectedItems.value = rows;
src/views/mes/md/item/data.ts
@@ -332,31 +332,31 @@
  ];
}
/** ç‰©æ–™é€‰æ‹©å¼¹çª—搜索表单 */
/** å“ç§é€‰æ‹©å¼¹çª—搜索表单 */
export function useItemSelectGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '物料编码',
      label: '品种编码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入物料编码',
        placeholder: '请输入品种编码',
      },
    },
    {
      fieldName: 'name',
      label: '物料名称',
      label: '品种名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入物料名称',
        placeholder: '请输入品种名称',
      },
    },
  ];
}
/** ç‰©æ–™é€‰æ‹©å¼¹çª—列表字段 */
/** å“ç§é€‰æ‹©å¼¹çª—列表字段 */
export function useItemSelectGridColumns(
  multiple = true,
): VxeTableGridOptions<MesMdItemApi.Item>['columns'] {
@@ -364,12 +364,12 @@
    { type: multiple ? 'checkbox' : 'radio', width: 50 },
    {
      field: 'code',
      title: '物料编码',
      title: '品种编码',
      width: 180,
    },
    {
      field: 'name',
      title: '物料名称',
      title: '品种名称',
      minWidth: 160,
      align: 'left',
    },
@@ -387,7 +387,7 @@
    },
    {
      field: 'itemOrProduct',
      title: '物料/产品',
      title: '品种/产品',
      width: 110,
      align: 'center',
      cellRender: {
src/views/mes/md/item/type/data.ts
@@ -22,7 +22,7 @@
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
/** æ–°å¢ž/修改物料分类的表单 */
/** æ–°å¢ž/修改品种分类的表单 */
export function useFormSchema(
  formType: FormType,
  formApi?: VbenFormApi,
@@ -99,7 +99,7 @@
    },
    {
      fieldName: 'itemOrProduct',
      label: '物料/产品标识',
      label: '品种/产品标识',
      component: 'RadioGroup',
      componentProps: {
        buttonStyle: 'solid',
@@ -185,7 +185,7 @@
    },
    {
      field: 'itemOrProduct',
      title: '物料/产品',
      title: '品种/产品',
      width: 130,
      align: 'center',
      cellRender: {
src/views/mes/md/item/type/index.vue
@@ -102,12 +102,12 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <Grid table-title="物料分类列表">
    <Grid table-title="品种分类列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['物料分类']),
              label: $t('ui.actionTitle.create', ['品种分类']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:md-item-type:create'],
src/views/mes/md/item/type/modules/form.vue
@@ -21,8 +21,8 @@
const formData = ref<MesMdItemTypeApi.ItemType>();
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['物料分类'])
    : $t('ui.actionTitle.create', ['物料分类']);
    ? $t('ui.actionTitle.edit', ['品种分类'])
    : $t('ui.actionTitle.create', ['品种分类']);
});
const [Form, formApi] = useVbenForm({
src/views/mes/pro/bagCode/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,130 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProBagCodeApi } from '#/api/mes/pro/bagCode';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getRangePickerDefaultProps } from '#/utils';
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '袋码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入袋码(二维码内容)',
      },
    },
    {
      fieldName: 'batchCode',
      label: '批次号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入批次号',
      },
    },
    {
      fieldName: 'uuid',
      label: '生成组',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入生成组编号',
      },
    },
    {
      fieldName: 'status',
      label: '袋码状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_PRO_BAG_CODE_STATUS, 'number'),
        placeholder: '请选择袋码状态',
      },
    },
    {
      fieldName: 'createTime',
      label: '创建时间',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<MesProBagCodeApi.BagCode>['columns'] {
  return [
    { type: 'checkbox', width: 50 },
    {
      field: 'code',
      title: '袋码(二维码内容)',
      fixed: 'left',
      minWidth: 220,
    },
    {
      field: 'bagNo',
      title: '袋序号',
      width: 80,
    },
    {
      field: 'uuid',
      title: '生成组',
      minWidth: 150,
    },
    {
      field: 'batchCode',
      title: '批次号',
      minWidth: 190,
    },
    {
      field: 'lineCode',
      title: '产线编码',
      width: 100,
    },
    {
      field: 'lineName',
      title: '产线名称',
      width: 120,
    },
    {
      field: 'palletCode',
      title: '绑定托盘码',
      minWidth: 190,
    },
    {
      field: 'status',
      title: '袋码状态',
      width: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_PRO_BAG_CODE_STATUS },
      },
    },
    {
      field: 'printTime',
      title: '导出印刷时间',
      width: 170,
      formatter: 'formatDateTime',
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 170,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/mes/pro/bagCode/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,273 @@
<script lang="ts" setup>
  import type { VxeTableGridOptions } from "#/adapter/vxe-table";
  import type { MesProBagCodeApi } from "#/api/mes/pro/bagCode";
  import { confirm, Page, useVbenModal } from "@vben/common-ui";
  import { MesProBagCodeStatusEnum } from "@vben/constants";
  import { downloadFileFromBlobPart } from "@vben/utils";
  import { message } from "ant-design-vue";
  import { ACTION_ICON, TableAction, useVbenVxeGrid } from "#/adapter/vxe-table";
  import {
    deleteBagCodesByUuid,
    exportBagCodeExcel,
    exportBagQr,
    getBagCodePage,
    unbindBagCodePallet,
  } from "#/api/mes/pro/bagCode";
  import { $t } from "#/locales";
  import { useGridColumns, useGridFormSchema } from "./data";
  import BindPalletModal from "./modules/bind-pallet.vue";
  import GenerateModal from "./modules/generate-form.vue";
  import QrTraceModal from "../components/qr-trace-modal.vue";
  defineOptions({ name: "MesProBagCode" });
  const [GenerateFormModal, generateFormModalApi] = useVbenModal({
    connectedComponent: GenerateModal,
    destroyOnClose: true,
  });
  const [BindPalletFormModal, bindPalletFormModalApi] = useVbenModal({
    connectedComponent: BindPalletModal,
    destroyOnClose: true,
  });
  const [QrTraceQueryModal, qrTraceQueryModalApi] = useVbenModal({
    connectedComponent: QrTraceModal,
    destroyOnClose: true,
  });
  /** åˆ·æ–°è¡¨æ ¼ */
  function handleRefresh() {
    gridApi.query();
  }
  /** ç”Ÿæˆæ•°æ®åŒ… */
  function handleGenerate() {
    generateFormModalApi.open();
  }
  /** æ‰«ç è¿½æº¯ */
  function handleTrace() {
    qrTraceQueryModalApi.open();
  }
  /** æŸ¥çœ‹äºŒç»´ç ï¼ˆè¡Œå†…预填码值) */
  function handleViewQr(row: MesProBagCodeApi.BagCode) {
    qrTraceQueryModalApi.setData({ code: row.code }).open();
  }
  /** å¯¼å‡ºäºŒç»´ç  ZIP(按当前查询条件的生成组/批次) */
  async function handleExportQr() {
    const values = await gridApi.formApi.getValues();
    const params: { uuid?: string; batchId?: number } = {};
    if (values.uuid) {
      params.uuid = values.uuid;
    }
    if (values.batchId) {
      params.batchId = values.batchId;
    }
    if (!params.uuid && !params.batchId) {
      message.warning("请先按生成组或批次筛选后再导出二维码");
      return;
    }
    const data = await exportBagQr(params);
    downloadFileFromBlobPart({ fileName: "袋码二维码.zip", source: data });
  }
  /** å¯¼å‡ºè¢‹ç  Excel(交付印刷),导出后袋码状态变为已印刷,需刷新列表 */
  async function handleExport() {
    const data = await exportBagCodeExcel(await gridApi.formApi.getValues());
    downloadFileFromBlobPart({ fileName: "袋码数据包.xls", source: data });
    message.success("导出成功,导出的袋码已标记为已印刷");
    handleRefresh();
  }
  /** èŽ·å–å‹¾é€‰çš„è¢‹ç è®°å½• */
  function getSelectedRecords(): MesProBagCodeApi.BagCode[] | undefined {
    const records = gridApi.grid.getCheckboxRecords() as MesProBagCodeApi.BagCode[];
    if (records.length === 0) {
      message.warning("请至少勾选一条袋码记录");
      return undefined;
    }
    return records;
  }
  /** æ‰¹é‡ç»‘定托盘 */
  function handleBindPallet() {
    const records = getSelectedRecords();
    if (!records) return;
    const batchIds = new Set(records.map((record) => record.batchId));
    if (batchIds.size > 1) {
      message.warning("请选择同一批次下的袋码进行绑定");
      return;
    }
    if (records.some((record) => record.palletId)) {
      message.warning("所选袋码中存在已绑定托盘的记录,请先解绑");
      return;
    }
    if (
      records.some(
        (record) => record.status === MesProBagCodeStatusEnum.CANCELED
      )
    ) {
      message.warning("已作废的袋码不允许绑定托盘");
      return;
    }
    const first = records[0]!;
    bindPalletFormModalApi
      .setData({
        batchId: first.batchId,
        batchCode: first.batchCode,
        ids: records.map((record) => record.id!),
      })
      .open();
  }
  /** æ‰¹é‡è§£ç»‘托盘 */
  async function handleUnbindPallet() {
    const records = getSelectedRecords();
    if (!records) return;
    if (records.some((record) => !record.palletId)) {
      message.warning("所选袋码中存在未绑定托盘的记录,无法解绑");
      return;
    }
    const palletIds = new Set(records.map((record) => record.palletId));
    if (palletIds.size > 1) {
      message.warning("请选择绑定在同一托盘上的袋码进行解绑");
      return;
    }
    try {
      await confirm(`确认解绑所选 ${records.length} æ¡è¢‹ç å—?`);
    } catch {
      return;
    }
    await unbindBagCodePallet({
      palletId: records[0]!.palletId!,
      ids: records.map((record) => record.id!),
    });
    message.success($t("ui.actionMessage.operationSuccess"));
    handleRefresh();
  }
  /** åˆ é™¤è¢‹ç ç”Ÿæˆç»„(数据包) */
  async function handleDeleteGroup(row: MesProBagCodeApi.BagCode) {
    await deleteBagCodesByUuid(row.uuid!);
    message.success($t("ui.actionMessage.deleteSuccess", [row.uuid]));
    handleRefresh();
  }
  const [Grid, gridApi] = useVbenVxeGrid({
    formOptions: {
      schema: useGridFormSchema(),
    },
    gridOptions: {
      checkboxConfig: {
        highlight: true,
        reserve: true,
      },
      columns: useGridColumns(),
      height: "auto",
      keepSource: true,
      proxyConfig: {
        ajax: {
          query: async ({ page }, formValues) => {
            return await getBagCodePage({
              pageNo: page.currentPage,
              pageSize: page.pageSize,
              ...formValues,
            });
          },
        },
      },
      rowConfig: {
        keyField: "id",
        isHover: true,
      },
      showOverflow: true,
      toolbarConfig: {
        refresh: true,
        search: true,
      },
    } as VxeTableGridOptions<MesProBagCodeApi.BagCode>,
  });
</script>
<template>
  <Page auto-content-height>
    <GenerateFormModal @success="handleRefresh" />
    <BindPalletFormModal @success="handleRefresh" />
    <QrTraceQueryModal />
    <Grid table-title="袋码列表(一袋一码)">
      <template #toolbar-tools>
        <TableAction :actions="[
            {
              label: '生成数据包',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-bag-code:create'],
              onClick: handleGenerate,
            },
            {
              label: '绑定托盘',
              type: 'primary',
              auth: ['mes:pro-bag-code:update'],
              onClick: handleBindPallet,
            },
            {
              label: '解绑托盘',
              auth: ['mes:pro-bag-code:update'],
              onClick: handleUnbindPallet,
            },
            {
              label: '扫码追溯',
              icon: ACTION_ICON.SEARCH,
              auth: ['mes:pro-bag-code:query'],
              onClick: handleTrace,
            },
            {
              label: '导出二维码',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pro-bag-code:export'],
              onClick: handleExportQr,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pro-bag-code:export'],
              onClick: handleExport,
            },
          ]" />
      </template>
      <template #actions="{ row }">
        <TableAction :actions="[
            {
              label: '查看二维码',
              type: 'link',
              auth: ['mes:pro-bag-code:query'],
              onClick: handleViewQr.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:pro-bag-code:delete'],
              ifShow:
                row.status === MesProBagCodeStatusEnum.GENERATED &&
                !row.palletId,
              popConfirm: {
                title:
                  '删除将移除该袋码所属的整个生成组(数据包),确认删除吗?',
                confirm: handleDeleteGroup.bind(null, row),
              },
            },
          ]" />
      </template>
    </Grid>
  </Page>
</template>
src/views/mes/pro/bagCode/modules/bind-pallet.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,121 @@
<script lang="ts" setup>
  import type { VbenFormSchema } from "#/adapter/form";
  import { useVbenModal } from "@vben/common-ui";
  import { MesProPalletStatusEnum } from "@vben/constants";
  import { message } from "ant-design-vue";
  import { useVbenForm } from "#/adapter/form";
  import { bindBagCodePallet } from "#/api/mes/pro/bagCode";
  import { getPalletSimpleList } from "#/api/mes/pro/pallet";
  import { $t } from "#/locales";
  defineOptions({ name: "MesProBagCodeBindPallet" });
  /** å¼¹çª—传入数据 */
  interface ModalData {
    batchId: number;
    batchCode?: string;
    ids: number[];
  }
  const emit = defineEmits(["success"]);
  /** æž„建表单(托盘下拉选项由外部加载注入) */
  function buildSchema(palletOptions: { label: string; value: number }[]): VbenFormSchema[] {
    return [
      {
        fieldName: "batchCode",
        label: "生产批次",
        component: "Input",
        componentProps: {
          disabled: true,
        },
      },
      {
        fieldName: "bagCount",
        label: "绑定袋数",
        component: "InputNumber",
        componentProps: {
          class: "!w-full",
          disabled: true,
        },
      },
      {
        fieldName: "palletId",
        label: "目标托盘",
        component: "Select",
        componentProps: {
          optionFilterProp: "label",
          options: palletOptions,
          placeholder: "请选择目标托盘",
          showSearch: true,
        },
        rules: "selectRequired",
      },
    ];
  }
  const [Form, formApi] = useVbenForm({
    commonConfig: {
      componentProps: {
        class: "w-full",
      },
      labelWidth: 100,
    },
    layout: "horizontal",
    schema: [],
    showDefaultActions: false,
  });
  const [Modal, modalApi] = useVbenModal({
    async onConfirm() {
      const { valid } = await formApi.validate();
      if (!valid) return;
      const data = modalApi.getData<ModalData>();
      const values = await formApi.getValues();
      modalApi.lock();
      try {
        await bindBagCodePallet({ palletId: values.palletId, ids: data.ids });
        message.success($t("ui.actionMessage.operationSuccess"));
        emit("success");
        await modalApi.close();
      } finally {
        modalApi.unlock();
      }
    },
    async onOpenChange(isOpen: boolean) {
      if (!isOpen) return;
      const data = modalApi.getData<ModalData>();
      modalApi.lock();
      try {
        // åŠ è½½è¯¥æ‰¹æ¬¡ä¸‹çš„æ‰˜ç›˜ï¼ˆæŽ’é™¤å·²ä½œåºŸï¼‰
        const pallets = await getPalletSimpleList(data.batchId);
        const options = pallets
          .filter(
            (pallet) => pallet.status !== MesProPalletStatusEnum.CANCELED
          )
          .map((pallet) => ({
            label: `${pallet.code}(已装 ${pallet.bagCount ?? 0} è¢‹ / å¯è£… ${
              pallet.capacity ?? "不限"
            } è¢‹ï¼‰`,
            value: pallet.id!,
          }));
        await formApi.setState({ schema: buildSchema(options) });
        await formApi.setValues({
          bagCount: data.ids.length,
          batchCode: data.batchCode,
        });
      } finally {
        modalApi.unlock();
      }
    },
  });
</script>
<template>
  <Modal title="袋码绑定托盘" class="w-2/5">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/mes/pro/bagCode/modules/generate-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,111 @@
<script lang="ts" setup>
  import type { VbenFormSchema } from "#/adapter/form";
  import type { MesProBagCodeApi } from "#/api/mes/pro/bagCode";
  import { useVbenModal } from "@vben/common-ui";
  import { MesProBatchStatusEnum } from "@vben/constants";
  import { message } from "ant-design-vue";
  import { useVbenForm } from "#/adapter/form";
  import { generateBagCodes } from "#/api/mes/pro/bagCode";
  import { getBatchSimpleList } from "#/api/mes/pro/batch";
  defineOptions({ name: "MesProBagCodeGenerateForm" });
  const emit = defineEmits(["success"]);
  /** æž„建表单(批次下拉选项由外部加载注入) */
  function buildSchema(batchOptions: { label: string; value: number }[]): VbenFormSchema[] {
    return [
      {
        fieldName: "batchId",
        label: "生产批次",
        component: "Select",
        componentProps: {
          options: batchOptions,
          optionFilterProp: "label",
          placeholder: "请选择生产中的批次",
          showSearch: true,
        },
        rules: "selectRequired",
      },
      {
        fieldName: "count",
        label: "生成袋数",
        component: "InputNumber",
        componentProps: {
          class: "!w-full",
          min: 1,
          placeholder: "请输入需要生成的袋码数量",
          precision: 0,
        },
        rules: "required",
      },
      {
        fieldName: "remark",
        label: "备注",
        component: "Textarea",
        componentProps: {
          placeholder: "请输入备注",
          rows: 2,
        },
      },
    ];
  }
  const [Form, formApi] = useVbenForm({
    commonConfig: {
      componentProps: {
        class: "w-full",
      },
      labelWidth: 100,
    },
    layout: "horizontal",
    schema: [],
    showDefaultActions: false,
  });
  const [Modal, modalApi] = useVbenModal({
    async onConfirm() {
      const { valid } = await formApi.validate();
      if (!valid) return;
      modalApi.lock();
      try {
        const values =
          (await formApi.getValues()) as MesProBagCodeApi.GenerateParams;
        await generateBagCodes(values);
        message.success(`已成功生成 ${values.count} æ¡è¢‹ç `);
        emit("success");
        await modalApi.close();
      } finally {
        modalApi.unlock();
      }
    },
    async onOpenChange(isOpen: boolean) {
      if (!isOpen) return;
      modalApi.lock();
      try {
        // ä»…生产中的批次允许生成袋码
        const batches = await getBatchSimpleList();
        const options = batches
          .filter(
            (batch) => batch.status === MesProBatchStatusEnum.PRODUCING
          )
          .map((batch) => ({
            label: batch.code!,
            value: batch.id!,
          }));
        await formApi.setState({ schema: buildSchema(options) });
      } finally {
        modalApi.unlock();
      }
    },
  });
</script>
<template>
  <Modal title="生成袋码数据包" class="w-2/5">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/mes/pro/batch/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,336 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProBatchApi } from '#/api/mes/pro/batch';
import { markRaw } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import { ProLineSelect } from '#/views/mes/pro/line/components';
import { UserSelect } from '#/views/system/user/components';
import { getRangePickerDefaultProps } from '#/utils';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
  const disabled = formType === 'detail';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'code',
      label: '批次号',
      component: 'Input',
      componentProps: {
        disabled: true,
        placeholder: '保存后自动生成',
      },
      dependencies: {
        triggerFields: [''],
        show: () => formType !== 'create',
      },
    },
    {
      fieldName: 'status',
      label: '批次状态',
      component: 'Select',
      componentProps: {
        disabled: true,
        options: getDictOptions(DICT_TYPE.MES_PRO_BATCH_STATUS, 'number'),
      },
      dependencies: {
        triggerFields: [''],
        show: () => formType !== 'create',
      },
    },
    {
      fieldName: 'itemId',
      label: '品种',
      component: markRaw(MdmItemSelect),
      componentProps: {
        disabled,
        placeholder: '请选择品种(产品品种)',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'lineId',
      label: '产线',
      component: markRaw(ProLineSelect),
      componentProps: {
        disabled,
        placeholder: '请选择产线',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'produceDate',
      label: '生产日期',
      component: 'DatePicker',
      componentProps: {
        class: '!w-full',
        disabled,
        format: 'YYYY-MM-DD HH:mm:ss',
        placeholder: '请选择生产日期',
        showTime: true,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
    },
    {
      fieldName: 'planQuantity',
      label: '计划数量',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        disabled,
        min: 0,
        placeholder: '请输入计划数量',
        precision: 2,
      },
    },
    {
      fieldName: 'ownerUserId',
      label: '责任人',
      component: markRaw(UserSelect),
      componentProps: {
        disabled,
        placeholder: '请选择责任人',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-3',
      componentProps: {
        disabled,
        placeholder: '请输入备注',
        rows: 2,
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '批次号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入批次号',
      },
    },
    {
      fieldName: 'itemId',
      label: '品种',
      component: markRaw(MdmItemSelect),
      componentProps: {
        placeholder: '请选择品种',
      },
    },
    {
      fieldName: 'lineCode',
      label: '产线编码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入产线编码',
      },
    },
    {
      fieldName: 'lineName',
      label: '产线名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入产线名称',
      },
    },
    {
      fieldName: 'ownerUserId',
      label: '责任人',
      component: markRaw(UserSelect),
      componentProps: {
        placeholder: '请选择责任人',
      },
    },
    {
      fieldName: 'status',
      label: '批次状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_PRO_BATCH_STATUS, 'number'),
        placeholder: '请选择批次状态',
      },
    },
    {
      fieldName: 'produceDate',
      label: '生产日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        allowClear: true,
      },
    },
    {
      fieldName: 'createTime',
      label: '创建时间',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<MesProBatchApi.Batch>['columns'] {
  return [
    {
      field: 'code',
      title: '批次号',
      fixed: 'left',
      minWidth: 200,
      slots: { default: 'code' },
    },
    {
      field: 'itemCode',
      title: '品种编码',
      width: 120,
    },
    {
      field: 'itemName',
      title: '品种名称',
      width: 140,
    },
    {
      field: 'lineCode',
      title: '产线编码',
      width: 100,
    },
    {
      field: 'lineName',
      title: '产线名称',
      width: 120,
    },
    {
      field: 'produceDate',
      title: '生产日期',
      width: 170,
      formatter: 'formatDateTime',
    },
    {
      field: 'planQuantity',
      title: '计划数量',
      width: 100,
    },
    {
      field: 'ownerUserName',
      title: '责任人',
      width: 100,
    },
    {
      field: 'bagQuantity',
      title: '袋码数量',
      width: 90,
    },
    {
      field: 'palletQuantity',
      title: '托盘码数量',
      width: 100,
    },
    {
      field: 'status',
      title: '批次状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_PRO_BATCH_STATUS },
      },
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 170,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 300,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
/** ç”Ÿäº§äººå‘˜é…ç½®å­è¡¨çš„字段(可编辑态) */
export function useWorkerEditGridColumns(): VxeTableGridOptions<MesProBatchApi.BatchWorker>['columns'] {
  return [
    {
      field: 'userId',
      title: '生产人员',
      minWidth: 200,
      slots: { default: 'userId' },
    },
    {
      field: 'workerType',
      title: '岗位类型',
      width: 160,
      slots: { default: 'workerType' },
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 160,
      slots: { default: 'remark' },
    },
    {
      title: '操作',
      width: 80,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
/** ç”Ÿäº§äººå‘˜é…ç½®å­è¡¨çš„字段(只读态) */
export function useWorkerReadGridColumns(): VxeTableGridOptions<MesProBatchApi.BatchWorker>['columns'] {
  return [
    {
      field: 'userName',
      title: '人员姓名',
      minWidth: 160,
    },
    {
      field: 'workerType',
      title: '岗位类型',
      width: 160,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_PRO_BATCH_WORKER_TYPE },
      },
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 160,
    },
  ];
}
src/views/mes/pro/batch/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,238 @@
<script lang="ts" setup>
  import type { VxeTableGridOptions } from "#/adapter/vxe-table";
  import type { MesProBatchApi } from "#/api/mes/pro/batch";
  import { Page, useVbenModal } from "@vben/common-ui";
  import { MesProBatchStatusEnum } from "@vben/constants";
  import { downloadFileFromBlobPart } from "@vben/utils";
  import { Button, message } from "ant-design-vue";
  import { ACTION_ICON, TableAction, useVbenVxeGrid } from "#/adapter/vxe-table";
  import {
    deleteBatch,
    exportBatchExcel,
    exportBatchQr,
    getBatchPage,
    updateBatchStatus,
  } from "#/api/mes/pro/batch";
  import { $t } from "#/locales";
  import { useGridColumns, useGridFormSchema } from "./data";
  import Form from "./modules/form.vue";
  import QrTraceModal from "../components/qr-trace-modal.vue";
  const [FormModal, formModalApi] = useVbenModal({
    connectedComponent: Form,
    destroyOnClose: true,
  });
  const [QrTraceQueryModal, qrTraceQueryModalApi] = useVbenModal({
    connectedComponent: QrTraceModal,
    destroyOnClose: true,
  });
  /** åˆ·æ–°è¡¨æ ¼ */
  function handleRefresh() {
    gridApi.query();
  }
  /** æ‰«ç è¿½æº¯ */
  function handleTrace() {
    qrTraceQueryModalApi.open();
  }
  /** æŸ¥çœ‹äºŒç»´ç ï¼ˆè¡Œå†…预填码值) */
  function handleViewQr(row: MesProBatchApi.Batch) {
    qrTraceQueryModalApi.setData({ code: row.code }).open();
  }
  /** æ‰“印整套二维码(批次图 + å…¨éƒ¨æ‰˜ç›˜å›¾ ZIP) */
  async function handlePrintQrSet(row: MesProBatchApi.Batch) {
    const data = await exportBatchQr(row.id!);
    downloadFileFromBlobPart({
      fileName: `批次二维码-${row.code}.zip`,
      source: data,
    });
  }
  /** æ–°å¢žæ‰¹æ¬¡ */
  function handleCreate() {
    formModalApi.setData({ formType: "create" }).open();
  }
  /** æŸ¥çœ‹è¯¦æƒ… */
  function handleDetail(row: MesProBatchApi.Batch) {
    formModalApi.setData({ formType: "detail", id: row.id }).open();
  }
  /** ç¼–辑批次 */
  function handleEdit(row: MesProBatchApi.Batch) {
    formModalApi.setData({ formType: "update", id: row.id }).open();
  }
  /** åˆ é™¤æ‰¹æ¬¡ */
  async function handleDelete(row: MesProBatchApi.Batch) {
    await deleteBatch(row.id!);
    message.success($t("ui.actionMessage.deleteSuccess", [row.code]));
    handleRefresh();
  }
  /** æ›´æ–°æ‰¹æ¬¡çŠ¶æ€ */
  async function handleUpdateStatus(
    row: MesProBatchApi.Batch,
    status: number,
    statusName: string
  ) {
    await updateBatchStatus(row.id!, status);
    message.success(`批次已${statusName}`);
    handleRefresh();
  }
  /** å¯¼å‡ºè¡¨æ ¼ */
  async function handleExport() {
    const data = await exportBatchExcel(await gridApi.formApi.getValues());
    downloadFileFromBlobPart({ fileName: "生产批次.xls", source: data });
  }
  const [Grid, gridApi] = useVbenVxeGrid({
    formOptions: {
      schema: useGridFormSchema(),
    },
    gridOptions: {
      columns: useGridColumns(),
      height: "auto",
      keepSource: true,
      proxyConfig: {
        ajax: {
          query: async ({ page }, formValues) => {
            return await getBatchPage({
              pageNo: page.currentPage,
              pageSize: page.pageSize,
              ...formValues,
            });
          },
        },
      },
      rowConfig: {
        keyField: "id",
        isHover: true,
      },
      toolbarConfig: {
        refresh: true,
        search: true,
      },
    } as VxeTableGridOptions<MesProBatchApi.Batch>,
  });
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <QrTraceQueryModal />
    <Grid table-title="生产批次列表">
      <template #toolbar-tools>
        <TableAction :actions="[
            {
              label: $t('ui.actionTitle.create', ['批次']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-batch:create'],
              onClick: handleCreate,
            },
            {
              label: '扫码追溯',
              icon: ACTION_ICON.SEARCH,
              auth: ['mes:pro-batch:query'],
              onClick: handleTrace,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pro-batch:export'],
              onClick: handleExport,
            },
          ]" />
      </template>
      <template #code="{ row }">
        <Button type="link" @click="handleDetail(row)">
          {{ row.code }}
        </Button>
      </template>
      <template #actions="{ row }">
        <TableAction :actions="[
            {
              label: '查看二维码',
              type: 'link',
              auth: ['mes:pro-batch:query'],
              onClick: handleViewQr.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:pro-batch:update'],
              ifShow:
                row.status === MesProBatchStatusEnum.DRAFT ||
                row.status === MesProBatchStatusEnum.PRODUCING,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:pro-batch:delete'],
              ifShow:
                row.status === MesProBatchStatusEnum.DRAFT ||
                row.status === MesProBatchStatusEnum.CANCELED,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.code]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
          :drop-down-actions="[
            {
              label: '开始生产',
              auth: ['mes:pro-batch:update'],
              ifShow: row.status === MesProBatchStatusEnum.DRAFT,
              popConfirm: {
                title: '确认开始生产该批次吗?',
                confirm: () =>
                  handleUpdateStatus(row, MesProBatchStatusEnum.PRODUCING, '开始生产'),
              },
            },
            {
              label: '完成',
              auth: ['mes:pro-batch:update'],
              ifShow: row.status === MesProBatchStatusEnum.PRODUCING,
              popConfirm: {
                title: '确认完成该批次吗?',
                confirm: () =>
                  handleUpdateStatus(row, MesProBatchStatusEnum.FINISHED, '完成'),
              },
            },
            {
              label: '作废',
              auth: ['mes:pro-batch:update'],
              ifShow:
                row.status === MesProBatchStatusEnum.DRAFT ||
                row.status === MesProBatchStatusEnum.PRODUCING,
              popConfirm: {
                title: '确认作废该批次吗?作废后不可恢复。',
                confirm: () =>
                  handleUpdateStatus(row, MesProBatchStatusEnum.CANCELED, '作废'),
              },
            },
            {
              label: '打印整套二维码',
              auth: ['mes:pro-batch:export'],
              onClick: () => handlePrintQrSet(row),
            },
          ]" />
      </template>
    </Grid>
  </Page>
</template>
src/views/mes/pro/batch/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,281 @@
<script lang="ts" setup>
  import type { FormType } from "../data";
  import type { VxeTableGridOptions } from "#/adapter/vxe-table";
  import type { MesProBatchApi } from "#/api/mes/pro/batch";
  import type { SystemUserApi } from "#/api/system/user";
  import { computed, nextTick, ref } from "vue";
  import { useVbenModal } from "@vben/common-ui";
  import { DICT_TYPE } from "@vben/constants";
  import { getDictOptions } from "@vben/hooks";
  import { Button, Input, message, Select } from "ant-design-vue";
  import { useVbenForm } from "#/adapter/form";
  import { TableAction, useVbenVxeGrid } from "#/adapter/vxe-table";
  import { createBatch, getBatch, updateBatch } from "#/api/mes/pro/batch";
  import { $t } from "#/locales";
  import { UserSelect } from "#/views/system/user/components";
  import {
    useFormSchema,
    useWorkerEditGridColumns,
    useWorkerReadGridColumns,
  } from "../data";
  defineOptions({ name: "MesProBatchForm" });
  const emit = defineEmits(["success"]);
  const formType = ref<FormType>("create");
  const formData = ref<MesProBatchApi.Batch>();
  const isEditable = computed(() =>
    ["create", "update"].includes(formType.value)
  );
  const getTitle = computed(() => {
    switch (formType.value) {
      case "detail": {
        return $t("ui.actionTitle.view", ["生产批次"]);
      }
      case "update": {
        return $t("ui.actionTitle.edit", ["生产批次"]);
      }
      default: {
        return $t("ui.actionTitle.create", ["生产批次"]);
      }
    }
  });
  const [Form, formApi] = useVbenForm({
    commonConfig: {
      componentProps: {
        class: "w-full",
      },
      formItemClass: "col-span-1",
      labelWidth: 110,
    },
    layout: "horizontal",
    schema: [],
    showDefaultActions: false,
    wrapperClass: "grid-cols-3",
  });
  // ========== ç”Ÿäº§äººå‘˜é…ç½®å­è¡¨ ==========
  const [WorkerGrid, workerGridApi] = useVbenVxeGrid({
    gridOptions: {
      autoResize: true,
      border: true,
      columns: useWorkerEditGridColumns(),
      minHeight: 160,
      pagerConfig: { enabled: false },
      rowConfig: { isHover: true, keyField: "_rowKey" },
      showOverflow: true,
      toolbarConfig: { enabled: false },
    } as VxeTableGridOptions<MesProBatchApi.BatchWorker>,
  });
  /** æ–°å¢žè¡Œçš„临时行号(负数自增,避免与 loadData çš„ index å†²çªï¼‰ */
  let workerRowSeq = 0;
  /** æ·»åŠ äººå‘˜è¡Œ */
  function handleAddWorker() {
    workerGridApi.grid.insertAt({ _rowKey: -(++workerRowSeq) }, -1);
  }
  /** åˆ é™¤äººå‘˜è¡Œ */
  function handleDeleteWorker(row: MesProBatchApi.BatchWorker) {
    workerGridApi.grid.remove(row);
  }
  /** äººå‘˜é€‰æ‹©ç¼–号变化(单选场景下取第一个值) */
  function handleWorkerUserIdChange(
    value: number | number[] | undefined,
    row: MesProBatchApi.BatchWorker
  ) {
    row.userId = Array.isArray(value) ? value[0] : value;
  }
  /** äººå‘˜é€‰æ‹©å˜åŒ–时记录姓名(便于校验提示) */
  function handleWorkerUserChange(
    user: SystemUserApi.User | SystemUserApi.User[] | undefined,
    row: MesProBatchApi.BatchWorker
  ) {
    row.userName = Array.isArray(user) ? user[0]?.nickname : user?.nickname;
  }
  /** å²—位类型字典选项 */
  const workerTypeOptions = computed(() =>
    getDictOptions(DICT_TYPE.MES_PRO_BATCH_WORKER_TYPE, "number").map(
      (item) => ({ label: item.label, value: item.value as number })
    )
  );
  /** æ ¡éªŒå¹¶èŽ·å–äººå‘˜é…ç½®åˆ—è¡¨ */
  async function getValidWorkers(): Promise<
    MesProBatchApi.BatchWorker[] | undefined
  > {
    // getData() åªè¿”回 loadData åŠ è½½çš„æ•°æ®ï¼›insertAt æ’入的行需通过
    // getInsertRecords() èŽ·å–ï¼Œå¹¶æŽ’é™¤ getRemoveRecords() ä¸­å·²åˆ é™¤çš„行
    const data = workerGridApi.grid.getData() as Array<
      MesProBatchApi.BatchWorker & { _rowKey?: number }
    >;
    const removeRecords = workerGridApi.grid.getRemoveRecords() as Array<
      MesProBatchApi.BatchWorker & { _rowKey?: number }
    >;
    const insertRecords = workerGridApi.grid.getInsertRecords() as Array<
      MesProBatchApi.BatchWorker & { _rowKey?: number }
    >;
    const rows = [
      ...data.filter(
        (row) => !removeRecords.some((removed) => removed._rowKey === row._rowKey)
      ),
      ...insertRecords,
    ].map(({ _rowKey, ...rest }) => rest);
    if (rows.length === 0) {
      message.warning("请至少配置一名生产人员");
      return undefined;
    }
    for (const [index, row] of rows.entries()) {
      if (!row.userId) {
        message.warning(`请选择第 ${index + 1} è¡Œçš„生产人员`);
        return undefined;
      }
      if (!row.workerType) {
        message.warning(`请选择第 ${index + 1} è¡Œçš„岗位类型`);
        return undefined;
      }
    }
    return rows;
  }
  /** ç­‰å¾…人员子表 grid æŒ‚载完成(弹窗打开瞬间 onOpenChange å†…部表格可能尚未挂载) */
  async function waitWorkerGridReady(): Promise<void> {
    await nextTick();
    for (let i = 0; i < 100; i++) {
      if (typeof workerGridApi.grid?.loadData === "function") {
        return;
      }
      await new Promise((resolve) => setTimeout(resolve, 30));
    }
  }
  const [Modal, modalApi] = useVbenModal({
    showCancelButton: false, // è‡ªå®šä¹‰å–消/关闭按钮,禁用默认取消按钮避免重复
    async onConfirm() {
      if (!isEditable.value) {
        await modalApi.close();
        return;
      }
      const { valid } = await formApi.validate();
      if (!valid) return;
      const workers = await getValidWorkers();
      if (!workers) return;
      modalApi.lock();
      try {
        const data = (await formApi.getValues()) as MesProBatchApi.Batch;
        if (formData.value?.id) {
          await updateBatch({ ...data, id: formData.value.id, workers });
        } else {
          await createBatch({ ...data, workers });
        }
        message.success($t("ui.actionMessage.operationSuccess"));
        emit("success");
        await modalApi.close();
      } finally {
        modalApi.unlock();
      }
    },
    async onOpenChange(isOpen: boolean) {
      if (!isOpen) {
        formData.value = undefined;
        return;
      }
      const data = modalApi.getData<{ formType: FormType; id?: number }>();
      formType.value = data.formType;
      formApi.setState({ schema: useFormSchema(formType.value) });
      // æ ¹æ®è¡¨å•态切换人员子表列
      workerGridApi.setGridOptions({
        columns: isEditable.value
          ? useWorkerEditGridColumns()
          : useWorkerReadGridColumns(),
      });
      if (data?.id) {
        modalApi.lock();
        try {
          formData.value = await getBatch(data.id);
          await formApi.setValues(formData.value);
          const workers = (formData.value.workers ?? []).map(
            (worker, index) => ({ ...worker, _rowKey: worker.id ?? index })
          );
          await waitWorkerGridReady();
          await workerGridApi.grid.loadData(workers);
        } finally {
          modalApi.unlock();
        }
      } else {
        await waitWorkerGridReady();
        await workerGridApi.grid.loadData([]);
      }
    },
  });
</script>
<template>
  <Modal :title="getTitle"
         class="w-3/5"
         content-class="!max-h-[80vh] overflow-y-auto">
    <Form class="mx-4" />
    <div class="mx-4 mb-4">
      <div class="mb-2 flex items-center justify-between">
        <span class="font-medium">生产人员配置</span>
        <Button v-if="isEditable" size="small" type="primary" @click="handleAddWorker">
          æ·»åŠ äººå‘˜
        </Button>
      </div>
      <WorkerGrid>
        <template #userId="{ row }">
          <UserSelect
            :model-value="row.userId"
            placeholder="请选择生产人员"
            @update:model-value="handleWorkerUserIdChange($event, row)"
            @change="handleWorkerUserChange($event, row)"
          />
        </template>
        <template #workerType="{ row }">
          <Select
            v-model:value="row.workerType"
            :options="workerTypeOptions"
            class="w-full"
            placeholder="请选择岗位类型"
          />
        </template>
        <template #remark="{ row }">
          <Input v-model:value="row.remark" placeholder="请输入备注" />
        </template>
        <template #actions="{ row }">
          <TableAction
            :actions="[
              {
                label: $t('common.delete'),
                type: 'link',
                danger: true,
                onClick: handleDeleteWorker.bind(null, row),
              },
            ]"
          />
        </template>
      </WorkerGrid>
    </div>
    <template #append-footer>
      <div class="flex gap-2">
        <Button @click="modalApi.close()">
          {{ isEditable ? '取消' : '关闭' }}
        </Button>
      </div>
    </template>
  </Modal>
</template>
src/views/mes/pro/components/qr-trace-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,569 @@
<script lang="ts" setup>
  import type { MesProBagCodeApi } from "#/api/mes/pro/bagCode";
  import type { MesProBatchApi } from "#/api/mes/pro/batch";
  import type { MesProPalletApi } from "#/api/mes/pro/pallet";
  import { ref } from "vue";
  import { Barcode, useVbenModal } from "@vben/common-ui";
  import { DICT_TYPE } from "@vben/constants";
  import { getDictLabel } from "@vben/hooks";
  import { Button, Empty, Input, message, Spin, Tag } from "ant-design-vue";
  import { getBagCodeByCode } from "#/api/mes/pro/bagCode";
  import { getBatchByCode } from "#/api/mes/pro/batch";
  import { getPalletByCode } from "#/api/mes/pro/pallet";
  import { useDescription } from "#/components/description";
  defineOptions({ name: "MesProQrTraceModal" });
  type TraceType = "batch" | "pallet" | "bag";
  const MAX_CODE_LENGTH = 64;
  const codeInput = ref<string>();
  const currentCode = ref<string>();
  const traceType = ref<TraceType>();
  const loading = ref(false);
  const searched = ref(false);
  const searchError = ref(false);
  const bagTrace = ref<MesProBagCodeApi.BagCodeTrace>();
  const palletTrace = ref<MesProPalletApi.PalletTrace>();
  const batchTrace = ref<MesProBatchApi.BatchTrace>();
  const barcodeRef = ref<InstanceType<typeof Barcode>>();
  const traceTypeLabel = ref("");
  /** è¯†åˆ«äºŒç»´ç å†…容类型:批次号 / æ‰˜ç›˜ç  / è¢‹ç  */
  function resolveCodeType(raw: string): TraceType | undefined {
    const value = raw.trim().toUpperCase();
    if (/^.*-P\d{2}$/.test(value)) {
      return "pallet";
    }
    if (/^.*-\d{4}$/.test(value)) {
      return "bag";
    }
    return "batch";
  }
  function resetState() {
    currentCode.value = undefined;
    traceType.value = undefined;
    traceTypeLabel.value = "";
    bagTrace.value = undefined;
    palletTrace.value = undefined;
    batchTrace.value = undefined;
    searched.value = false;
    searchError.value = false;
  }
  /** æ‰«ç /输入二维码内容查询追溯信息 */
  async function handleSearch(raw?: string) {
    const value = (raw ?? codeInput.value)?.trim().toUpperCase();
    if (!value) {
      message.warning("请扫描或输入二维码内容");
      return;
    }
    if (value.length > MAX_CODE_LENGTH) {
      message.warning("二维码内容过长,请检查是否扫描正确");
      return;
    }
    const type = resolveCodeType(value);
    if (!type) {
      message.warning("无法识别二维码类型");
      return;
    }
    loading.value = true;
    searched.value = true;
    searchError.value = false;
    currentCode.value = value;
    traceType.value = undefined;
    traceTypeLabel.value = "";
    bagTrace.value = undefined;
    palletTrace.value = undefined;
    batchTrace.value = undefined;
    try {
      if (type === "batch") {
        batchTrace.value = await getBatchByCode(value);
        traceTypeLabel.value = "批次号";
      } else if (type === "pallet") {
        palletTrace.value = await getPalletByCode(value);
        traceTypeLabel.value = "托盘码";
      } else {
        bagTrace.value = await getBagCodeByCode(value);
        traceTypeLabel.value = "袋码";
      }
      traceType.value = type;
    } catch {
      searchError.value = true;
      currentCode.value = undefined;
      traceType.value = undefined;
    } finally {
      loading.value = false;
    }
  }
  /** å½“前追溯数据的关键信息(下载/打印图片叠加文字用) */
  function buildLabelText(): { label: string; value: string }[] {
    if (traceType.value === "bag" && bagTrace.value) {
      const d = bagTrace.value;
      return [
        { label: "袋码", value: d.code || "" },
        { label: "袋序号", value: d.bagNo ? String(d.bagNo) : "" },
        { label: "批次号", value: d.batchCode || "" },
        { label: "品种", value: d.itemName || "" },
        { label: "规格", value: d.itemSpecification || "" },
        {
          label: "产线",
          value: [d.lineCode, d.lineName].filter(Boolean).join(" / "),
        },
        { label: "生产日期", value: d.produceDate || "" },
        { label: "责任人", value: d.ownerUserName || "" },
      ];
    }
    if (traceType.value === "pallet" && palletTrace.value) {
      const d = palletTrace.value;
      return [
        { label: "托盘码", value: d.code || "" },
        { label: "托盘序号", value: d.palletNo ? String(d.palletNo) : "" },
        { label: "批次号", value: d.batchCode || "" },
        { label: "品种", value: d.itemName || "" },
        { label: "规格", value: d.itemSpecification || "" },
        {
          label: "产线",
          value: [d.lineCode, d.lineName].filter(Boolean).join(" / "),
        },
        { label: "生产日期", value: d.produceDate || "" },
        { label: "责任人", value: d.ownerUserName || "" },
      ];
    }
    if (batchTrace.value) {
      const d = batchTrace.value;
      return [
        { label: "批次号", value: d.code || "" },
        { label: "品种", value: d.itemName || "" },
        { label: "规格", value: d.itemSpecification || "" },
        {
          label: "产线",
          value: [d.lineCode, d.lineName].filter(Boolean).join(" / "),
        },
        { label: "生产日期", value: d.produceDate || "" },
        { label: "计划数量", value: d.planQuantity ? String(d.planQuantity) : "" },
        { label: "责任人", value: d.ownerUserName || "" },
      ];
    }
    return [];
  }
  function escapeHtml(value: string) {
    return value
      .replaceAll("&", "&amp;")
      .replaceAll("<", "&lt;")
      .replaceAll(">", "&gt;");
  }
  /** ä¸‹è½½äºŒç»´ç  PNG(二维码 + å…³é”®ä¿¡æ¯æ–‡å­—合成) */
  function handleDownload() {
    const base64 = barcodeRef.value?.getImageBase64();
    if (!base64) {
      message.warning("二维码生成失败,无法下载");
      return;
    }
    const items = buildLabelText();
    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d");
    if (!ctx) {
      message.error("Canvas åˆå§‹åŒ–失败");
      return;
    }
    const img = new Image();
    img.onload = () => {
      const padding = 20;
      const infoX = img.width + padding * 2;
      const infoWidth = 320;
      canvas.width = infoX + infoWidth + padding;
      canvas.height = Math.max(img.height + padding * 2, 300);
      ctx.fillStyle = "#ffffff";
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(img, padding, padding);
      ctx.fillStyle = "#000000";
      let y = padding + 10;
      const lineHeight = 26;
      items.forEach((item) => {
        ctx.font = "bold 14px 'Microsoft YaHei', Arial, sans-serif";
        ctx.fillText(`${item.label}:`, infoX, y);
        y += 18;
        ctx.font = "14px 'Microsoft YaHei', Arial, sans-serif";
        const text = item.value || "-";
        const maxWidth = infoWidth - 10;
        if (ctx.measureText(text).width > maxWidth) {
          let currentText = text;
          while (currentText.length > 0) {
            let line = "";
            for (let i = 0; i < currentText.length; i++) {
              const testLine = line + currentText[i];
              if (ctx.measureText(testLine).width > maxWidth) {
                break;
              }
              line = testLine;
            }
            ctx.fillText(line, infoX + 5, y);
            y += 16;
            currentText = currentText.slice(line.length);
          }
          y += 6;
        } else {
          ctx.fillText(text, infoX + 5, y);
          y += lineHeight;
        }
      });
      const link = document.createElement("a");
      link.href = canvas.toDataURL("image/png");
      link.download = `${currentCode.value || "qr"}_${Date.now()}.png`;
      link.click();
      message.success("下载成功");
    };
    img.src = base64;
  }
  /** æ‰“印二维码 */
  function handlePrint() {
    const base64 = barcodeRef.value?.getImageBase64();
    if (!base64) {
      message.warning("二维码生成失败,无法打印");
      return;
    }
    const items = buildLabelText();
    const printWindow = window.open("", "_blank");
    if (!printWindow) {
      message.error("无法打开打印窗口,请检查浏览器设置");
      return;
    }
    printWindow.document.write(`<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>打印二维码</title>
<style>*{margin:0;padding:0}body{font-family:'Microsoft YaHei',Arial,sans-serif;padding:20px}.print-container{text-align:center}.qr-img{max-width:100%;margin:20px 0}.info{margin-top:20px;text-align:left;font-size:13px}.info p{margin:4px 0}@media print{body{padding:0}.print-container{padding:20px}}</style>
</head><body><div class="print-container">
<img src="${base64}" class="qr-img" alt="二维码" />
<div class="info">
${items.map((item) => `<p><strong>${escapeHtml(item.label)}:</strong> ${escapeHtml(item.value || "-")}</p>`).join("")}
</div></div></body></html>`);
    printWindow.document.close();
    printWindow.addEventListener("load", () => {
      setTimeout(() => printWindow.print(), 500);
    });
  }
  const [Modal, modalApi] = useVbenModal({
    onOpenChange(isOpen: boolean) {
      if (!isOpen) {
        return;
      }
      const data = modalApi.getData<{ code?: string }>() || {};
      codeInput.value = data.code ?? undefined;
      resetState();
      if (data.code) {
        handleSearch(data.code);
      }
    },
  });
  const [BagDescriptions] = useDescription({
    bordered: true,
    column: 2,
    schema: [
      {
        field: "code",
        label: "袋码(二维码内容)",
        span: 2,
      },
      {
        field: "bagNo",
        label: "袋序号",
      },
      {
        field: "status",
        label: "袋码状态",
        render: (val) =>
          val ? getDictLabel(DICT_TYPE.MES_PRO_BAG_CODE_STATUS, val) : "",
      },
      {
        field: "batchCode",
        label: "生产批次号",
      },
      {
        field: "batchStatus",
        label: "批次状态",
        render: (val) =>
          val ? getDictLabel(DICT_TYPE.MES_PRO_BATCH_STATUS, val) : "",
      },
      {
        field: "itemName",
        label: "品种",
      },
      {
        field: "itemSpecification",
        label: "规格",
      },
      {
        field: "produceDate",
        label: "生产日期",
      },
      {
        field: "ownerUserName",
        label: "责任人",
      },
      {
        field: "lineCode",
        label: "产线",
        render: (val, data) =>
          [val, data?.lineName].filter(Boolean).join(" / "),
      },
      {
        field: "palletCode",
        label: "绑定托盘码",
        render: (val) => val || "未绑定",
      },
      {
        field: "printTime",
        label: "导出印刷时间",
      },
    ],
  });
  const [PalletDescriptions] = useDescription({
    bordered: true,
    column: 2,
    schema: [
      {
        field: "code",
        label: "托盘码(二维码内容)",
        span: 2,
      },
      {
        field: "palletNo",
        label: "托盘序号",
      },
      {
        field: "capacity",
        label: "可装袋数(袋/托盘)",
      },
      {
        field: "bagCount",
        label: "已装袋数",
      },
      {
        field: "status",
        label: "托盘状态",
        render: (val) =>
          val ? getDictLabel(DICT_TYPE.MES_PRO_PALLET_STATUS, val) : "",
      },
      {
        field: "batchCode",
        label: "生产批次号",
      },
      {
        field: "batchStatus",
        label: "批次状态",
        render: (val) =>
          val ? getDictLabel(DICT_TYPE.MES_PRO_BATCH_STATUS, val) : "",
      },
      {
        field: "itemName",
        label: "品种",
      },
      {
        field: "itemSpecification",
        label: "规格",
      },
      {
        field: "produceDate",
        label: "生产日期",
      },
      {
        field: "ownerUserName",
        label: "责任人",
      },
      {
        field: "lineCode",
        label: "产线",
        render: (val, data) =>
          [val, data?.lineName].filter(Boolean).join(" / "),
      },
    ],
  });
  const [BatchDescriptions] = useDescription({
    bordered: true,
    column: 2,
    schema: [
      {
        field: "code",
        label: "批次号(二维码内容)",
        span: 2,
      },
      {
        field: "status",
        label: "批次状态",
        render: (val) =>
          val ? getDictLabel(DICT_TYPE.MES_PRO_BATCH_STATUS, val) : "",
      },
      {
        field: "itemName",
        label: "品种",
      },
      {
        field: "itemSpecification",
        label: "规格",
      },
      {
        field: "lineCode",
        label: "产线",
        render: (val, data) =>
          [val, data?.lineName].filter(Boolean).join(" / "),
      },
      {
        field: "produceDate",
        label: "生产日期",
      },
      {
        field: "planQuantity",
        label: "计划数量",
      },
      {
        field: "bagQuantity",
        label: "已生成袋码数量",
      },
      {
        field: "palletQuantity",
        label: "已生成托盘码数量",
      },
      {
        field: "ownerUserName",
        label: "责任人",
      },
      {
        field: "remark",
        label: "备注",
      },
    ],
  });
</script>
<template>
  <Modal
    :show-confirm-button="false"
    cancel-text="关闭"
    class="w-3/5"
    title="扫码追溯 / æŸ¥çœ‹äºŒç»´ç "
  >
    <div class="mx-4">
      <div class="mb-4 flex gap-2">
        <Input
          v-model:value="codeInput"
          allow-clear
          class="!w-96"
          placeholder="请扫描或输入批次号 / æ‰˜ç›˜ç  / è¢‹ç ï¼Œå›žè½¦æŸ¥è¯¢"
          @press-enter="() => handleSearch()"
        />
        <Button :loading="loading" type="primary" @click="() => handleSearch()">
          æŸ¥è¯¢
        </Button>
      </div>
      <Spin :spinning="loading">
        <template v-if="currentCode && traceType">
          <div
            class="mb-4 flex items-center gap-6 rounded bg-gray-100 p-5"
          >
            <div class="flex-shrink-0">
              <Barcode ref="barcodeRef" :content="currentCode" :width="200" />
            </div>
            <div class="min-w-0 flex-1">
              <p class="text-lg font-bold break-all">{{ currentCode }}</p>
              <p class="mt-1 text-xs text-gray-500">
                ç ç±»åž‹ï¼š{{ traceTypeLabel }}
              </p>
            </div>
          </div>
          <template v-if="traceType === 'batch' && batchTrace">
            <BatchDescriptions :data="batchTrace" class="mt-2" />
            <div
              v-if="batchTrace.workers?.length"
              class="mt-3 rounded border border-solid border-gray-200 p-3"
            >
              <p class="mb-2 font-bold">
                ç”Ÿäº§äººå‘˜ï¼ˆ{{ batchTrace.workers.length }})
              </p>
              <div class="flex flex-wrap gap-2">
                <Tag v-for="w in batchTrace.workers" :key="w.id">
                  {{ w.userName
                  }}<template v-if="w.workerType"
                    >({{
                      getDictLabel(DICT_TYPE.MES_PRO_BATCH_WORKER_TYPE, w.workerType)
                    }})</template
                  >
                </Tag>
              </div>
            </div>
            <div
              v-if="batchTrace.pallets?.length"
              class="mt-3 rounded border border-solid border-gray-200 p-3"
            >
              <p class="mb-2 font-bold">
                æ‰˜ç›˜ç ï¼ˆ{{ batchTrace.pallets.length }})
              </p>
              <div class="flex flex-wrap gap-2">
                <Tag v-for="p in batchTrace.pallets" :key="p.id">
                  {{ p.code }}
                </Tag>
              </div>
            </div>
            <div
              v-if="batchTrace.bags?.length"
              class="mt-3 rounded border border-solid border-gray-200 p-3"
            >
              <p class="mb-2 font-bold">
                è¢‹ç 
                <span
                  v-if="
                    batchTrace.bagTotal != null &&
                    batchTrace.bagTotal > batchTrace.bags.length
                  "
                  class="text-xs text-gray-400"
                >
                  ï¼ˆä»…展示前 {{ batchTrace.bags.length }} æ¡ï¼Œå…±
                  {{ batchTrace.bagTotal }} æ¡ï¼‰
                </span>
              </p>
              <div class="flex max-h-48 flex-wrap gap-2 overflow-y-auto">
                <Tag v-for="b in batchTrace.bags" :key="b.id">
                  {{ b.code }}
                </Tag>
              </div>
            </div>
          </template>
          <BagDescriptions
            v-else-if="traceType === 'bag' && bagTrace"
            :data="bagTrace"
            class="mt-2"
          />
          <PalletDescriptions
            v-else-if="traceType === 'pallet' && palletTrace"
            :data="palletTrace"
            class="mt-2"
          />
        </template>
        <Empty
          v-else-if="searched"
          :description="searchError ? '该码不存在或已删除' : '未查询到追溯信息'"
        />
      </Spin>
    </div>
    <template v-if="currentCode && traceType" #append-footer>
      <div class="flex gap-2">
        <Button type="primary" @click="handlePrint">打印</Button>
        <Button @click="handleDownload">下载二维码</Button>
      </div>
    </template>
  </Modal>
</template>
src/views/mes/pro/line/components/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1 @@
export { default as ProLineSelect } from './select.vue';
src/views/mes/pro/line/components/select.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,113 @@
<script lang="ts" setup>
import type { MesProLineApi } from '#/api/mes/pro/line';
import { computed, onMounted, ref, watch } from 'vue';
import { Select, Tag, Tooltip } from 'ant-design-vue';
import { getLineSimpleList } from '#/api/mes/pro/line';
defineOptions({ name: 'ProLineSelect', inheritAttrs: false });
const props = withDefaults(
  defineProps<{
    allowClear?: boolean;
    disabled?: boolean;
    modelValue?: number;
    placeholder?: string;
  }>(),
  {
    allowClear: true,
    disabled: false,
    modelValue: undefined,
    placeholder: '请选择产线',
  },
);
const emit = defineEmits<{
  change: [item: MesProLineApi.Line | undefined];
  'update:modelValue': [value: number | undefined];
}>();
const allList = ref<MesProLineApi.Line[]>([]);
const selectedItem = ref<MesProLineApi.Line>();
const selectValue = computed({
  get: () => props.modelValue,
  set: (value: number | undefined) => {
    emit('update:modelValue', value);
  },
});
/** å‰ç«¯è¿‡æ»¤ï¼šæŒ‰äº§çº¿åç§°æˆ–编码模糊匹配 */
function handleFilter(input: string, option: any) {
  const keyword = input.toLowerCase();
  const item = option?.item as MesProLineApi.Line | undefined;
  return Boolean(
    item?.name?.toLowerCase().includes(keyword) ||
    item?.code?.toLowerCase().includes(keyword),
  );
}
/** æ ¹æ®å½“前值同步 tooltip å±•示的产线详情 */
function syncSelectedItem(value: number | undefined) {
  selectedItem.value =
    value === undefined
      ? undefined
      : allList.value.find((item) => item.id === value);
}
/** é™¤ v-model å¤–,额外抛出完整产线对象给业务表单使用 */
function handleChange(value: any) {
  const nextValue = value === undefined ? undefined : Number(value);
  syncSelectedItem(nextValue);
  emit('change', selectedItem.value);
}
watch(
  () => props.modelValue,
  (value) => {
    syncSelectedItem(value);
  },
);
onMounted(async () => {
  allList.value = await getLineSimpleList();
  syncSelectedItem(props.modelValue);
});
</script>
<template>
  <Tooltip :mouse-enter-delay="0.5" :open="selectedItem ? undefined : false">
    <template #title>
      <div v-if="selectedItem" class="leading-6">
        <div>编码:{{ selectedItem.code || '-' }}</div>
        <div>名称:{{ selectedItem.name || '-' }}</div>
        <div>备注:{{ selectedItem.remark || '-' }}</div>
      </div>
    </template>
    <Select
      v-bind="$attrs"
      v-model:value="selectValue"
      :allow-clear="allowClear"
      :disabled="disabled"
      :filter-option="handleFilter"
      :placeholder="placeholder"
      class="w-full"
      show-search
      @change="handleChange"
    >
      <Select.Option
        v-for="item in allList"
        :key="item.id"
        :item="item"
        :value="item.id"
      >
        <div class="flex items-center gap-2">
          <span>{{ item.name }}</span>
          <Tag v-if="item.code" color="default">{{ item.code }}</Tag>
        </div>
      </Select.Option>
    </Select>
  </Tooltip>
</template>
src/views/mes/pro/line/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,152 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProLineApi } from '#/api/mes/pro/line';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getRangePickerDefaultProps } from '#/utils';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
/** æ–°å¢ž/修改产线的表单 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
  const disabled = formType === 'detail';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'code',
      label: '产线编码',
      component: 'Input',
      componentProps: {
        disabled,
        maxLength: 100,
        placeholder: '请输入产线编码(参与批次号生成)',
      },
      rules: z.string().min(1, '产线编码不能为空').max(100),
    },
    {
      fieldName: 'name',
      label: '产线名称',
      component: 'Input',
      componentProps: {
        disabled,
        maxLength: 100,
        placeholder: '请输入产线名称',
      },
      rules: z.string().min(1, '产线名称不能为空').max(100),
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'RadioGroup',
      componentProps: {
        buttonStyle: 'solid',
        disabled,
        optionType: 'button',
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
      },
      defaultValue: CommonStatusEnum.ENABLE,
      rules: z.number().default(CommonStatusEnum.ENABLE),
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        disabled,
        maxLength: 250,
        placeholder: '请输入备注',
        rows: 2,
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '产线编码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入产线编码',
      },
    },
    {
      fieldName: 'name',
      label: '产线名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入产线名称',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
        placeholder: '请选择状态',
      },
    },
    {
      fieldName: 'createTime',
      label: '创建时间',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<MesProLineApi.Line>['columns'] {
  return [
    {
      field: 'code',
      title: '产线编码',
      minWidth: 150,
      slots: { default: 'code' },
    },
    { field: 'name', title: '产线名称', minWidth: 180 },
    {
      field: 'status',
      title: '状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.COMMON_STATUS },
      },
    },
    { field: 'remark', title: '备注', minWidth: 180 },
    {
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 180,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/mes/pro/line/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,143 @@
<script lang="ts" setup>
  import type { VxeTableGridOptions } from "#/adapter/vxe-table";
  import type { MesProLineApi } from "#/api/mes/pro/line";
  import { Page, useVbenModal } from "@vben/common-ui";
  import { downloadFileFromBlobPart } from "@vben/utils";
  import { Button, message } from "ant-design-vue";
  import { ACTION_ICON, TableAction, useVbenVxeGrid } from "#/adapter/vxe-table";
  import {
    deleteLine,
    exportLineExcel,
    getLinePage,
  } from "#/api/mes/pro/line";
  import { $t } from "#/locales";
  import { useGridColumns, useGridFormSchema } from "./data";
  import Form from "./modules/form.vue";
  defineOptions({ name: "MesProLine" });
  const [FormModal, formModalApi] = useVbenModal({
    connectedComponent: Form,
    destroyOnClose: true,
  });
  /** åˆ·æ–°è¡¨æ ¼ */
  function handleRefresh() {
    gridApi.query();
  }
  /** æ–°å¢žäº§çº¿ */
  function handleCreate() {
    formModalApi.setData({ formType: "create" }).open();
  }
  /** æŸ¥çœ‹è¯¦æƒ… */
  function handleDetail(row: MesProLineApi.Line) {
    formModalApi.setData({ formType: "detail", id: row.id }).open();
  }
  /** ç¼–辑产线 */
  function handleEdit(row: MesProLineApi.Line) {
    formModalApi.setData({ formType: "update", id: row.id }).open();
  }
  /** åˆ é™¤äº§çº¿ */
  async function handleDelete(row: MesProLineApi.Line) {
    await deleteLine(row.id!);
    message.success($t("ui.actionMessage.deleteSuccess", [row.name]));
    handleRefresh();
  }
  /** å¯¼å‡ºè¡¨æ ¼ */
  async function handleExport() {
    const data = await exportLineExcel(await gridApi.formApi.getValues());
    downloadFileFromBlobPart({ fileName: "产线维护.xls", source: data });
  }
  const [Grid, gridApi] = useVbenVxeGrid({
    formOptions: {
      schema: useGridFormSchema(),
    },
    gridOptions: {
      columns: useGridColumns(),
      height: "auto",
      keepSource: true,
      proxyConfig: {
        ajax: {
          query: async ({ page }, formValues) => {
            return await getLinePage({
              pageNo: page.currentPage,
              pageSize: page.pageSize,
              ...formValues,
            });
          },
        },
      },
      rowConfig: {
        keyField: "id",
        isHover: true,
      },
      toolbarConfig: {
        refresh: true,
        search: true,
      },
    } as VxeTableGridOptions<MesProLineApi.Line>,
  });
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="产线列表">
      <template #toolbar-tools>
        <TableAction :actions="[
            {
              label: $t('ui.actionTitle.create', ['产线']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-line:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pro-line:export'],
              onClick: handleExport,
            },
          ]" />
      </template>
      <template #code="{ row }">
        <Button type="link" @click="handleDetail(row)">
          {{ row.code }}
        </Button>
      </template>
      <template #actions="{ row }">
        <TableAction :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:pro-line:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:pro-line:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.name]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]" />
      </template>
    </Grid>
  </Page>
</template>
src/views/mes/pro/line/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,111 @@
<script lang="ts" setup>
  import type { FormType } from "../data";
  import type { MesProLineApi } from "#/api/mes/pro/line";
  import { computed, ref } from "vue";
  import { useVbenModal } from "@vben/common-ui";
  import { Button, message } from "ant-design-vue";
  import { useVbenForm } from "#/adapter/form";
  import { createLine, getLine, updateLine } from "#/api/mes/pro/line";
  import { $t } from "#/locales";
  import { useFormSchema } from "../data";
  defineOptions({ name: "MesProLineForm" });
  const emit = defineEmits(["success"]);
  const formType = ref<FormType>("create");
  const formData = ref<MesProLineApi.Line>();
  const isEditable = computed(() =>
    ["create", "update"].includes(formType.value)
  );
  const getTitle = computed(() => {
    switch (formType.value) {
      case "detail": {
        return $t("ui.actionTitle.view", ["产线"]);
      }
      case "update": {
        return $t("ui.actionTitle.edit", ["产线"]);
      }
      default: {
        return $t("ui.actionTitle.create", ["产线"]);
      }
    }
  });
  const [Form, formApi] = useVbenForm({
    commonConfig: {
      componentProps: {
        class: "w-full",
      },
      formItemClass: "col-span-1",
      labelWidth: 100,
    },
    layout: "horizontal",
    schema: [],
    showDefaultActions: false,
    wrapperClass: "grid-cols-2",
  });
  const [Modal, modalApi] = useVbenModal({
    async onConfirm() {
      if (!isEditable.value) {
        await modalApi.close();
        return;
      }
      const { valid } = await formApi.validate();
      if (!valid) return;
      modalApi.lock();
      try {
        const data = (await formApi.getValues()) as MesProLineApi.Line;
        if (formData.value?.id) {
          await updateLine({ ...data, id: formData.value.id });
        } else {
          await createLine(data);
        }
        message.success($t("ui.actionMessage.operationSuccess"));
        emit("success");
        await modalApi.close();
      } finally {
        modalApi.unlock();
      }
    },
    async onOpenChange(isOpen: boolean) {
      if (!isOpen) {
        formData.value = undefined;
        return;
      }
      const data = modalApi.getData<{ formType: FormType; id?: number }>();
      formType.value = data.formType;
      formApi.setState({ schema: useFormSchema(formType.value) });
      if (data?.id) {
        modalApi.lock();
        try {
          formData.value = await getLine(data.id);
          await formApi.setValues(formData.value);
        } finally {
          modalApi.unlock();
        }
      }
    },
  });
</script>
<template>
  <Modal :title="getTitle" class="w-2/5">
    <Form class="mx-4" />
    <template #append-footer>
      <div class="flex gap-2">
        <Button @click="modalApi.close()">
          {{ isEditable ? '取消' : '关闭' }}
        </Button>
      </div>
    </template>
  </Modal>
</template>
src/views/mes/pro/pallet/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,109 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProPalletApi } from '#/api/mes/pro/pallet';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getRangePickerDefaultProps } from '#/utils';
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '托盘码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入托盘码',
      },
    },
    {
      fieldName: 'batchCode',
      label: '批次号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入批次号',
      },
    },
    {
      fieldName: 'status',
      label: '托盘状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_PRO_PALLET_STATUS, 'number'),
        placeholder: '请选择托盘状态',
      },
    },
    {
      fieldName: 'createTime',
      label: '创建时间',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<MesProPalletApi.Pallet>['columns'] {
  return [
    {
      field: 'code',
      title: '托盘码',
      fixed: 'left',
      minWidth: 210,
    },
    {
      field: 'palletNo',
      title: '托盘序号',
      width: 90,
    },
    {
      field: 'batchCode',
      title: '批次号',
      minWidth: 190,
    },
    {
      field: 'capacity',
      title: '可装袋数(袋/托盘)',
      width: 150,
    },
    {
      field: 'bagCount',
      title: '已绑袋码数量',
      width: 120,
    },
    {
      field: 'status',
      title: '托盘状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_PRO_PALLET_STATUS },
      },
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 140,
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 170,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/mes/pro/pallet/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,169 @@
<script lang="ts" setup>
  import type { VxeTableGridOptions } from "#/adapter/vxe-table";
  import type { MesProPalletApi } from "#/api/mes/pro/pallet";
  import { Page, useVbenModal } from "@vben/common-ui";
  import { downloadFileFromBlobPart } from "@vben/utils";
  import { message } from "ant-design-vue";
  import { ACTION_ICON, TableAction, useVbenVxeGrid } from "#/adapter/vxe-table";
  import {
    deletePallet,
    exportPalletExcel,
    exportPalletQr,
    getPalletPage,
  } from "#/api/mes/pro/pallet";
  import { $t } from "#/locales";
  import { useGridColumns, useGridFormSchema } from "./data";
  import GenerateModal from "./modules/generate-form.vue";
  import QrTraceModal from "../components/qr-trace-modal.vue";
  defineOptions({ name: "MesProPallet" });
  const [GenerateFormModal, generateFormModalApi] = useVbenModal({
    connectedComponent: GenerateModal,
    destroyOnClose: true,
  });
  const [QrTraceQueryModal, qrTraceQueryModalApi] = useVbenModal({
    connectedComponent: QrTraceModal,
    destroyOnClose: true,
  });
  /** åˆ·æ–°è¡¨æ ¼ */
  function handleRefresh() {
    gridApi.query();
  }
  /** æ‰¹é‡ç”Ÿæˆæ‰˜ç›˜ç  */
  function handleCreate() {
    generateFormModalApi.open();
  }
  /** æ‰«ç è¿½æº¯ */
  function handleTrace() {
    qrTraceQueryModalApi.open();
  }
  /** æŸ¥çœ‹äºŒç»´ç ï¼ˆè¡Œå†…预填码值) */
  function handleViewQr(row: MesProPalletApi.Pallet) {
    qrTraceQueryModalApi.setData({ code: row.code }).open();
  }
  /** å¯¼å‡ºäºŒç»´ç  ZIP(按当前查询条件的批次) */
  async function handleExportQr() {
    const values = await gridApi.formApi.getValues();
    if (!values.batchId) {
      message.warning("请先按批次筛选后再导出二维码");
      return;
    }
    const data = await exportPalletQr(values.batchId);
    downloadFileFromBlobPart({ fileName: "托盘码二维码.zip", source: data });
  }
  /** åˆ é™¤æ‰˜ç›˜ç  */
  async function handleDelete(row: MesProPalletApi.Pallet) {
    await deletePallet(row.id!);
    message.success($t("ui.actionMessage.deleteSuccess", [row.code]));
    handleRefresh();
  }
  /** å¯¼å‡ºè¡¨æ ¼ */
  async function handleExport() {
    const data = await exportPalletExcel(await gridApi.formApi.getValues());
    downloadFileFromBlobPart({ fileName: "托盘码.xls", source: data });
  }
  const [Grid, gridApi] = useVbenVxeGrid({
    formOptions: {
      schema: useGridFormSchema(),
    },
    gridOptions: {
      columns: useGridColumns(),
      height: "auto",
      keepSource: true,
      proxyConfig: {
        ajax: {
          query: async ({ page }, formValues) => {
            return await getPalletPage({
              pageNo: page.currentPage,
              pageSize: page.pageSize,
              ...formValues,
            });
          },
        },
      },
      rowConfig: {
        keyField: "id",
        isHover: true,
      },
      toolbarConfig: {
        refresh: true,
        search: true,
      },
    } as VxeTableGridOptions<MesProPalletApi.Pallet>,
  });
</script>
<template>
  <Page auto-content-height>
    <GenerateFormModal @success="handleRefresh" />
    <QrTraceQueryModal />
    <Grid table-title="托盘码列表">
      <template #toolbar-tools>
        <TableAction :actions="[
            {
              label: '批量生成',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-pallet:create'],
              onClick: handleCreate,
            },
            {
              label: '扫码追溯',
              icon: ACTION_ICON.SEARCH,
              auth: ['mes:pro-pallet:query'],
              onClick: handleTrace,
            },
            {
              label: '导出二维码',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pro-pallet:export'],
              onClick: handleExportQr,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pro-pallet:export'],
              onClick: handleExport,
            },
          ]" />
      </template>
      <template #actions="{ row }">
        <TableAction :actions="[
            {
              label: '查看二维码',
              type: 'link',
              auth: ['mes:pro-pallet:query'],
              onClick: handleViewQr.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:pro-pallet:delete'],
              ifShow: !row.bagCount,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.code]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]" />
      </template>
    </Grid>
  </Page>
</template>
src/views/mes/pro/pallet/modules/generate-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,122 @@
<script lang="ts" setup>
  import type { VbenFormSchema } from "#/adapter/form";
  import type { MesProPalletApi } from "#/api/mes/pro/pallet";
  import { useVbenModal } from "@vben/common-ui";
  import { MesProBatchStatusEnum } from "@vben/constants";
  import { message } from "ant-design-vue";
  import { useVbenForm } from "#/adapter/form";
  import { getBatchSimpleList } from "#/api/mes/pro/batch";
  import { batchCreatePallet } from "#/api/mes/pro/pallet";
  defineOptions({ name: "MesProPalletGenerateForm" });
  const emit = defineEmits(["success"]);
  /** æž„建表单(批次下拉选项由外部加载注入) */
  function buildSchema(batchOptions: { label: string; value: number }[]): VbenFormSchema[] {
    return [
      {
        fieldName: "batchId",
        label: "生产批次",
        component: "Select",
        componentProps: {
          options: batchOptions,
          optionFilterProp: "label",
          placeholder: "请选择生产中的批次",
          showSearch: true,
        },
        rules: "selectRequired",
      },
      {
        fieldName: "count",
        label: "生成数量",
        component: "InputNumber",
        componentProps: {
          class: "!w-full",
          min: 1,
          placeholder: "请输入需要生成的托盘数量",
          precision: 0,
        },
        rules: "required",
      },
      {
        fieldName: "capacity",
        label: "可装袋数",
        component: "InputNumber",
        componentProps: {
          class: "!w-full",
          min: 1,
          placeholder: "请输入每托盘可装袋数(袋/托盘)",
          precision: 0,
        },
      },
      {
        fieldName: "remark",
        label: "备注",
        component: "Textarea",
        componentProps: {
          placeholder: "请输入备注",
          rows: 2,
        },
      },
    ];
  }
  const [Form, formApi] = useVbenForm({
    commonConfig: {
      componentProps: {
        class: "w-full",
      },
      labelWidth: 100,
    },
    layout: "horizontal",
    schema: [],
    showDefaultActions: false,
  });
  const [Modal, modalApi] = useVbenModal({
    async onConfirm() {
      const { valid } = await formApi.validate();
      if (!valid) return;
      modalApi.lock();
      try {
        const values =
          (await formApi.getValues()) as MesProPalletApi.BatchCreateParams;
        const ids = await batchCreatePallet(values);
        message.success(`已成功生成 ${ids.length} ä¸ªæ‰˜ç›˜ç `);
        emit("success");
        await modalApi.close();
      } finally {
        modalApi.unlock();
      }
    },
    async onOpenChange(isOpen: boolean) {
      if (!isOpen) return;
      modalApi.lock();
      try {
        // ä»…生产中的批次允许生成托盘码
        const batches = await getBatchSimpleList();
        const options = batches
          .filter(
            (batch) => batch.status === MesProBatchStatusEnum.PRODUCING
          )
          .map((batch) => ({
            label: batch.code!,
            value: batch.id!,
          }));
        await formApi.setState({ schema: buildSchema(options) });
      } finally {
        modalApi.unlock();
      }
    },
  });
</script>
<template>
  <Modal title="批量生成托盘码" class="w-2/5">
    <Form class="mx-4" />
  </Modal>
</template>