8 天以前 fdd158306bf6dc3584f5110a385323f4a8dfb463
feat(wms): 重构仓库管理系统并优化图表显示

- 添加MES仓库类型枚举定义,支持暂存库和合格库
- 实现农业主题色彩方案,统一绿色系视觉风格
- 重构销售相关业务逻辑,移除销售订单关联功能
- 更新首页统计面板数据结构,增加多种业务指标
- 优化图表配置,提升数据可视化效果和一致性
- 调整扫码事件API接口,规范扫码类型参数
- 完善发货通知单库存管理,限制从合格库选择库存
- 优化退货单处理流程,简化物料选择逻辑
- 移除冗余组件文件,精简代码结构
- 调整表格列宽配置,优化用户界面布局
已删除2个文件
已添加12个文件
已修改37个文件
2567 ■■■■ 文件已修改
package.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pnpm-lock.yaml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/adapter/vxe-table.ts 17 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/sale/order/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/bag-code-home/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/scanEvent/index.ts 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/seedQuality/index.ts 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/trace-cross-region/index.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/trace/batch.ts 150 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/materialstock/index.ts 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/warehouse/index.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-mes-enum.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/plugins/src/echarts/echarts.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/chart-options.ts 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/data.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/index.vue 120 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/alert-panel.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/bag-code-cards.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/basic-cards.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/business-flow.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/kpi-cards.vue 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/scan-region-chart.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/bagCodeHome/modules/shortcuts.vue 98 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/index.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/scan-in/index.vue 144 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/TraceDecision.vue 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/TraceGraph.vue 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/TraceStageOverview.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/TraceTimeline.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/trace-detail.vue 148 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/seedQuality/index.vue 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/seedQuality/record.vue 284 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/components/index.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/components/select-dialog.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/components/select.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/components/stock-bag-modal.vue 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/data.ts 21 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/index.vue 41 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/data.ts 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/data.ts 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/modules/form.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/modules/line-form.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/modules/line-list.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/salesnotice/components/select-dialog.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/salesnotice/components/select.vue 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/salesnotice/data.ts 135 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/salesnotice/modules/form.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/salesnotice/modules/line-form.vue 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transaction/data.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transaction/index.vue 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/data.ts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json
@@ -60,6 +60,7 @@
    "cropperjs": "catalog:",
    "dayjs": "catalog:",
    "dhtmlx-gantt": "catalog:",
    "echarts": "catalog:",
    "diagram-js": "catalog:",
    "fast-xml-parser": "catalog:",
    "gsap": "^3.15.0",
pnpm-lock.yaml
@@ -644,6 +644,9 @@
      diagram-js:
        specifier: 'catalog:'
        version: 15.17.0
      echarts:
        specifier: 'catalog:'
        version: 6.1.0
      fast-xml-parser:
        specifier: 'catalog:'
        version: 4.5.6
src/adapter/vxe-table.ts
@@ -121,8 +121,9 @@
    // è¡¨æ ¼é…ç½®é¡¹å¯ä»¥ç”¨ cellRender: { name: 'CellTag' },
    // props: { data: [{value, color}], labels?: {[value]: label} }
    // ä¹Ÿå…¼å®¹ options æ ¼å¼: [{ label, value, color }]
    // æ”¯æŒä¸¤ç§åŒ¹é…æ¨¡å¼:
    //   1. ç²¾ç¡®åŒ¹é…: data å’Œ labels ä¸­éƒ½æ˜¯ç²¾ç¡®å€¼ (状态/类型枚举)
    //   1. ç²¾ç¡®åŒ¹é…: data/labels/options ä¸­éƒ½æ˜¯ç²¾ç¡®å€¼ (状态/类型枚举)
    //   2. é˜ˆå€¼åŒ¹é…: data ä¸ºé™åºé˜ˆå€¼, label æ˜¾ç¤ºåŽŸå§‹æ•°å€¼ (评分)
    vxeUI.renderer.add('CellTag', {
      renderTableDefault(renderOpts, params) {
@@ -130,15 +131,25 @@
        const { column, row } = params;
        const rawValue = row[column.field];
        // å…¼å®¹ options æ ¼å¼ï¼Œç²¾ç¡®åŒ¹é…æ˜¾ç¤ºæ–‡æœ¬ä¸Žé¢œè‰²
        const matchedOption = props?.options?.find(
          (item: any) =>
            item.value === rawValue || Number(item.value) === Number(rawValue),
        );
        // æŸ¥æ‰¾æ˜¾ç¤ºæ–‡æœ¬
        let label = rawValue;
        if (props?.labels && rawValue !== undefined && rawValue !== null) {
        if (matchedOption) {
          label = matchedOption.label;
        } else if (props?.labels && rawValue !== undefined && rawValue !== null) {
          label = props.labels[rawValue] ?? rawValue;
        }
        // æŸ¥æ‰¾é¢œè‰²: ä¼˜å…ˆç²¾ç¡®åŒ¹é…ï¼Œå¦åˆ™é˜ˆå€¼åŒ¹é…
        let color: string | undefined;
        if (props?.data) {
        if (matchedOption) {
          color = matchedOption.color;
        } else if (props?.data) {
          const exact = props.data.find(
            (item: any) => item.value === rawValue,
          );
src/api/erp/sale/order/index.ts
@@ -29,8 +29,6 @@
    needProduction?: number; // æ˜¯å¦éœ€è¦ç”Ÿäº§ï¼š0-不需要,1-需要
    productionStatus?: number; // ç”Ÿäº§çŠ¶æ€ï¼š0-未完成,1-已完成
    productionFinishTime?: Date; // ç”Ÿäº§å®Œæˆæ—¶é—´
    hasSalesNotice?: boolean; // æ˜¯å¦å·²ç”Ÿæˆå‘货通知单
    canCreateSalesNotice?: boolean; // æ˜¯å¦å¯ä»¥ç”Ÿæˆå‘货通知单
    items?: SaleOrderItem[]; // é”€å”®è®¢å•产品明细列表
  }
src/api/mes/bag-code-home/index.ts
@@ -17,6 +17,11 @@
    batchCount: number; // æ‰¹æ¬¡æ€»æ•°
    bagCodeCount: number; // è¢‹ç æ€»æ•°
    palletCount: number; // æ‰˜ç›˜ç æ€»æ•°
    salesNoticeCount: number; // å‘货通知单数
    productSalesCount: number; // é”€å”®å‡ºåº“单数
    returnSalesCount: number; // é”€å”®é€€è´§å•æ•°
    stockWarningPendingCount: number; // æœªå¤„理库存预警数
    ncrPendingCount: number; // å¾…评审不合格品单数
    batchStatusList: StatusItem[]; // æ‰¹æ¬¡çŠ¶æ€åˆ†å¸ƒ
    bagCodeStatusList: StatusItem[]; // è¢‹ç çŠ¶æ€åˆ†å¸ƒ
  }
src/api/mes/pro/scanEvent/index.ts
@@ -3,10 +3,14 @@
import { requestClient } from '#/api/request';
export namespace MesProScanEventApi {
  /** æ‰«ç ç±»åž‹ï¼šè¢‹ç  / æ‰˜ç›˜ç  / æ‰¹æ¬¡ç  */
  export type ScanType = 'BAG' | 'PALLET' | 'BATCH';
  export interface ScanEvent {
    id?: number;
    eventId?: string;
    bagCode?: string;
    scanType?: ScanType;
    batchCode?: string;
    quantity?: number;
    deviceCode?: string;
@@ -31,8 +35,9 @@
  export interface InboundParams {
    eventId: string;
    bagCode: string;
    scanType: ScanType;
    quantity: number;
    deviceCode: string;
    deviceCode?: string;
    lineCode?: string;
    locationId: number;
    areaId: number;
src/api/mes/qc/seedQuality/index.ts
@@ -1,5 +1,24 @@
import { requestClient } from '#/api/request';
export interface SeedQualityPageParams {
  pageNo?: number;
  pageSize?: number;
  code?: string;
  batchCode?: string;
  result?: number;
}
export interface SeedQualityRecord {
  id: number;
  code: string;
  batchCode?: string;
  result: number;
  reason?: string;
  inspectorId?: number;
  inspectedAt?: string;
  createTime?: string;
}
export namespace MesQcSeedQualityApi {
  export interface Indicator {
    code: string;
@@ -10,13 +29,17 @@
  export interface SubmitParams {
    code: string;
    itemId: number;
    batchCode?: string;
    indicators: Indicator[];
    reason?: string;
    qualifiedWarehouseId: number; // ç›®æ ‡åˆæ ¼åº“(质检合格后入该仓库,仓库类型为合格库20)
  }
}
export function getSeedQualityPage(params: SeedQualityPageParams) {
  return requestClient.get<{ list: SeedQualityRecord[]; total: number }>('/mes/qc/seed-quality/page', { params });
}
export function submitSeedQuality(data: MesQcSeedQualityApi.SubmitParams) {
  return requestClient.post<number>('/mes/qc/seed-quality/submit', data);
}
src/api/mes/trace-cross-region/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
import { requestClient } from '#/api/request';
export namespace MesTraceCrossRegionApi {
  /** çªœè´§é¢„警汇总 */
  export interface Summary {
    total?: number; // é¢„警总数
    pending?: number; // å¾…处理数量
    handled?: number; // å·²å¤„理数量
    falseAlarm?: number; // è¯¯æŠ¥æ•°é‡
    crossProvince?: number; // è·¨çœçªœè´§æ•°é‡
    crossCity?: number; // è·¨å¸‚窜货数量
  }
}
/** èŽ·å¾—çªœè´§é¢„è­¦æ±‡æ€» */
export function getTraceCrossRegionSummary(startTime?: string, endTime?: string) {
  return requestClient.get<MesTraceCrossRegionApi.Summary>(
    '/mes/trace-cross-region/summary',
    { params: { startTime, endTime } },
  );
}
src/api/mes/trace/batch.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,150 @@
import { requestClient } from '#/api/request';
export namespace MesTraceBatchApi {
  export interface Worker {
    workerType?: number;
    workerTypeName?: string;
    userName?: string;
    remark?: string;
  }
  export interface Pallet {
    code?: string;
    palletNo?: number;
    bagCount?: number;
    capacity?: number;
  }
  export interface Bag {
    code?: string;
    bagNo?: number;
    palletCode?: string;
    status?: number;
    statusName?: string;
    inboundTime?: string;
  }
  export interface Inbound {
    eventId?: string;
    deviceCode?: string;
    lineCode?: string;
    quantity?: number;
    status?: string;
    eventTime?: string;
  }
  export interface Indicator {
    indicatorCode?: string;
    indicatorName?: string;
    value?: number;
    minValue?: number;
    maxValue?: number;
    pass?: boolean;
  }
  export interface Quality {
    result?: number;
    resultName?: string;
    reason?: string;
    inspectorName?: string;
    inspectedAt?: string;
    indicators?: Indicator[];
  }
  export interface PurchaseSource {
    receiptCode?: string;
    receiptDate?: string;
    purchaseOrderCode?: string;
    vendorCode?: string;
    vendorName?: string;
    iqcCode?: string;
    checkResult?: number;
    itemCode?: string;
    itemName?: string;
    receivedQuantity?: number;
  }
  export interface SalesTarget {
    salesCode?: string;
    salesDate?: string;
    salesOrderCode?: string;
    clientName?: string;
    oqcCode?: string;
    checkResult?: number;
    itemCode?: string;
    itemName?: string;
    quantity?: number;
    qualityStatus?: number;
  }
  export interface ProcessTrace {
    eventType?: 'OUT' | 'IN';
    workOrderCode?: string;
    processCode?: string;
    processName?: string;
    quantity?: number;
    qualityStatus?: number;
    eventTime?: string;
  }
  export interface Timeline {
    stage?: string;
    eventType?: string;
    eventName?: string;
    eventTime?: string;
    quantity?: number;
    status?: string;
  }
  export interface Decision {
    qualityRisk?: boolean;
    inboundQuantity?: number;
    salesTargetCount?: number;
    consumerScanCount?: number;
    riskLevel?: string;
  }
  export interface Detail {
    sourceType?: string;
    requestCode?: string;
    itemCode?: string;
    itemName?: string;
    itemSpecification?: string;
    batchCode?: string;
    tempCode?: string;
    batchStatus?: number;
    batchStatusName?: string;
    produceDate?: string;
    lineCode?: string;
    lineName?: string;
    ownerUserName?: string;
    planQuantity?: number;
    bagQuantity?: number;
    palletQuantity?: number;
    inboundTime?: string;
    remark?: string;
    workers?: Worker[];
    palletCount?: number;
    pallets?: Pallet[];
    bagTotal?: number;
    bags?: Bag[];
    inbound?: Inbound;
    quality?: Quality;
    antiFake?: {
      scanCount?: number;
      firstScanTime?: string;
      lastScanTime?: string;
    };
    purchaseSources?: PurchaseSource[];
    salesTargets?: SalesTarget[];
    processTraces?: ProcessTrace[];
    timeline?: Timeline[];
    decision?: Decision;
  }
}
export function getBatchTraceDetail(code: string) {
  return requestClient.get<MesTraceBatchApi.Detail>('/mes/trace/batch/detail', {
    params: { code },
  });
}
src/api/mes/wm/materialstock/index.ts
@@ -34,6 +34,9 @@
    warningLevel?: 'LOW_STOCK' | 'EXPIRING' | 'AGEING' | null; // é£Žé™©ç±»åž‹
    receiptTime?: string; // å…¥åº“日期
    frozen?: boolean; // æ˜¯å¦å†»ç»“
    bagCount?: number; // å½“前袋码数量
    availableBagCount?: number; // å¯ç”¨è¢‹ç æ•°é‡
    bagDetailSupported?: boolean; // æ˜¯å¦å­˜åœ¨è¢‹ç æ˜Žç»†
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
@@ -52,8 +55,14 @@
  }
}
/** åº“存台账分页查询参数 */
export interface MaterialStockPageParams extends PageParam {
  warehouseType?: number; // ä»“库类型:10暂存库,20合格库(按类型过滤仓库)
  stockState?: number; // åº“存状态:10暂存库,20合格库,30已出库
}
/** æŸ¥è¯¢åº“存台账分页 */
export function getMaterialStockPage(params: PageParam) {
export function getMaterialStockPage(params: MaterialStockPageParams) {
  return requestClient.get<PageResult<MesWmMaterialStockApi.MaterialStock>>(
    '/mes/wm/material-stock/page',
    { params },
@@ -61,6 +70,36 @@
}
/** æŸ¥è¯¢åº“存台账详情 */
export interface MaterialStockBagDetail {
  id: number;
  materialStockId: number;
  bagCodeId?: number;
  bagCode: string;
  batchId?: number;
  batchCode?: string;
  warehouseId?: number;
  locationId?: number;
  areaId?: number;
  stockState?: number;
  status?: number;
  quantity?: number;
  frozen?: boolean;
  inboundTime?: string;
  outboundTime?: string;
}
export function getMaterialStockBagDetails(params: {
  bagCode?: string;
  materialStockId: number;
  pageNo?: number;
  pageSize?: number;
}) {
  return requestClient.get<PageResult<MaterialStockBagDetail>>(
    '/mes/wm/material-stock/bag-details',
    { params },
  );
}
export function getMaterialStock(id: number) {
  return requestClient.get<MesWmMaterialStockApi.MaterialStock>(
    `/mes/wm/material-stock/get?id=${id}`,
@@ -160,6 +199,7 @@
/** åº“存汇总查询参数 */
export interface MaterialStockSummaryParams {
  stockState?: number; // åº“存状态:10暂存库,20合格库,30已出库
  warehouseType?: number; // ä»“库类型:10暂存库,20合格库(按类型过滤仓库)
  itemId?: number; // å“ç§ID
  batchCode?: string; // æ‰¹æ¬¡å·
  warehouseId?: number; // ä»“库ID
src/api/mes/wm/warehouse/index.ts
@@ -12,6 +12,7 @@
    area?: number; // é¢ç§¯
    chargeUserId?: number; // è´Ÿè´£äºº
    frozen?: boolean; // æ˜¯å¦å†»ç»“
    type?: number; // ä»“库类型:10暂存库,20合格库
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
src/packages/constants/src/biz-mes-enum.ts
@@ -233,6 +233,12 @@
  OUT: 30, // å·²å‡ºåº“
} as const;
/** MES ä»“库类型枚举(对齐后端 MesWmWarehouseTypeEnum) */
export const MesWmWarehouseTypeEnum = {
  TEMP: 10, // æš‚存库(如虚拟线边仓库,存放扫码入库待检/暂存库存)
  QUALIFIED: 20, // åˆæ ¼åº“(存放质检合格、可拣货库存)
} as const;
/** MES åº“存预警类型枚举 */
export const MesWmStockWarningTypeEnum = {
  LOW_STOCK: 10, // ä½Žåº“å­˜
src/packages/effects/plugins/src/echarts/echarts.ts
@@ -3,6 +3,7 @@
  EffectScatterChart,
  FunnelChart,
  GaugeChart,
  GraphChart,
  LineChart,
  LinesChart,
  MapChart,
@@ -48,6 +49,7 @@
  LineChart,
  FunnelChart,
  GaugeChart,
  GraphChart,
  ScatterChart,
  EffectScatterChart,
  LinesChart,
src/views/dashboard/bagCodeHome/chart-options.ts
@@ -1,5 +1,16 @@
import type { EChartsOption } from '@vben/plugins/echarts';
/** å†œä¸šä¸»é¢˜ï¼šç¿ ç»¿ä¸»è‰²ï¼ˆå¯¹é½ç™»å½•页,浅色玻璃版) */
export const AGRI_GREEN = '#10b981';
export const AGRI_GREEN_LIGHT = '#34d399';
export const AGRI_GREEN_PALE = '#6ee7b7';
export const AGRI_TEXT = '#134e4a';
export const AGRI_TEXT_MUTED = 'rgba(15, 78, 60, 0.62)';
export const AGRI_SPLIT = 'rgba(16, 185, 129, 0.18)';
/** æ·±åº•通用文本样式 */
const darkText: NonNullable<EChartsOption['textStyle']> = { color: AGRI_TEXT };
/** è¢‹ç ç”Ÿæˆè¶‹åŠ¿æŸ±çŠ¶å›¾é…ç½® */
export function getBagCodeTrendChartOptions(
  dates: string[],
@@ -7,19 +18,31 @@
): EChartsOption {
  return {
    grid: { bottom: 40, left: 50, right: 20, top: 20 },
    legend: { bottom: 0, data: ['袋码生成量'] },
    legend: { bottom: 0, data: ['袋码生成量'], textStyle: { color: AGRI_TEXT_MUTED } },
    series: [
      {
        barMaxWidth: 32,
        data: counts,
        itemStyle: { borderRadius: 4, color: '#409EFF' },
        itemStyle: { borderRadius: 4, color: AGRI_GREEN },
        name: '袋码生成量',
        type: 'bar',
      },
    ],
    textStyle: darkText,
    tooltip: { axisPointer: { type: 'shadow' }, trigger: 'axis' },
    xAxis: { axisTick: { show: false }, data: dates, type: 'category' },
    yAxis: { minInterval: 1, type: 'value' },
    xAxis: {
      axisLine: { lineStyle: { color: AGRI_SPLIT } },
      axisLabel: { color: AGRI_TEXT_MUTED },
      axisTick: { show: false },
      data: dates,
      type: 'category',
    },
    yAxis: {
      minInterval: 1,
      splitLine: { lineStyle: { color: AGRI_SPLIT } },
      axisLabel: { color: AGRI_TEXT_MUTED },
      type: 'value',
    },
  };
}
@@ -28,18 +51,53 @@
  data: Array<{ itemStyle: { color: string }; name: string; value: number }>,
): EChartsOption {
  return {
    legend: { bottom: 0, type: 'scroll' },
    legend: { bottom: 0, textStyle: { color: AGRI_TEXT_MUTED }, 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 },
        itemStyle: { borderColor: '#ffffff', borderRadius: 6, borderWidth: 2 },
        label: { color: AGRI_TEXT, formatter: '{b}\n{c}', show: true },
        radius: ['40%', '70%'],
        type: 'pie',
      },
    ],
    textStyle: darkText,
    tooltip: { formatter: '{b}: {c} ({d}%)', trigger: 'item' },
  };
}
/** æ¶ˆè´¹è€…扫码地区分布横向柱状图配置 */
export function getRegionBarChartOptions(
  data: Array<{ name: string; count: number }>,
): EChartsOption {
  const sorted = [...data].sort((a, b) => b.count - a.count);
  return {
    grid: { bottom: 20, left: 16, right: 40, top: 20, containLabel: true },
    series: [
      {
        barMaxWidth: 20,
        data: sorted.map((d) => d.count),
        itemStyle: { borderRadius: 4, color: AGRI_GREEN },
        name: '扫码次数',
        type: 'bar',
      },
    ],
    textStyle: darkText,
    tooltip: { axisPointer: { type: 'shadow' }, trigger: 'axis' },
    xAxis: {
      axisLabel: { color: AGRI_TEXT_MUTED },
      axisLine: { lineStyle: { color: AGRI_SPLIT } },
      splitLine: { lineStyle: { color: AGRI_SPLIT } },
      type: 'value',
    },
    yAxis: {
      axisLabel: { color: AGRI_TEXT },
      axisLine: { lineStyle: { color: AGRI_SPLIT } },
      data: sorted.map((d) => d.name),
      type: 'category',
      inverse: true,
    },
  };
}
src/views/dashboard/bagCodeHome/data.ts
@@ -10,21 +10,26 @@
  lineCount: 0,
  mdmItemCount: 0,
  mesItemCount: 0,
  ncrPendingCount: 0,
  palletCount: 0,
  productSalesCount: 0,
  returnSalesCount: 0,
  salesNoticeCount: 0,
  stockWarningPendingCount: 0,
  unitMeasureCount: 0,
};
/** æ‰¹æ¬¡çŠ¶æ€å¯¹åº”çš„é¢œè‰²æ˜ å°„ï¼ˆçŠ¶æ€å­—å…¸ï¼š10 è‰ç¨¿ 20 ç”Ÿäº§ä¸­ 30 å·²å®Œæˆ 40 å·²ä½œåºŸï¼‰ */
/** æ‰¹æ¬¡çŠ¶æ€å¯¹åº”çš„é¢œè‰²æ˜ å°„ï¼ˆçŠ¶æ€å­—å…¸ï¼š10 è‰ç¨¿ 20 ç”Ÿäº§ä¸­ 30 å·²å®Œæˆ 40 å·²ä½œåºŸï¼‰å¯¹é½å†œä¸šç¿ ç»¿ä¸»é¢˜ */
export const BATCH_STATUS_COLOR_MAP: Record<number, string> = {
  10: '#909399', // è‰ç¨¿
  20: '#409EFF', // ç”Ÿäº§ä¸­
  30: '#67C23A', // å·²å®Œæˆ
  40: '#F56C6C', // å·²ä½œåºŸ
  10: '#94a3b8', // è‰ç¨¿
  20: '#10b981', // ç”Ÿäº§ä¸­
  30: '#34d399', // å·²å®Œæˆ
  40: '#fca5a5', // å·²ä½œåºŸ
};
/** è¢‹ç çŠ¶æ€å¯¹åº”çš„é¢œè‰²æ˜ å°„ï¼ˆçŠ¶æ€å­—å…¸ï¼š10 å·²ç”Ÿæˆ 20 å·²å¯¼å‡ºå°åˆ· 30 å·²ä½œåºŸï¼‰ */
export const BAG_CODE_STATUS_COLOR_MAP: Record<number, string> = {
  10: '#409EFF', // å·²ç”Ÿæˆ
  20: '#E6A23C', // å·²å¯¼å‡ºå°åˆ·
  30: '#F56C6C', // å·²ä½œåºŸ
  10: '#10b981', // å·²ç”Ÿæˆ
  20: '#e6a23c', // å·²å¯¼å‡ºå°åˆ·
  30: '#fca5a5', // å·²ä½œåºŸ
};
src/views/dashboard/bagCodeHome/index.vue
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home';
import type { MesTraceCrossRegionApi } from '#/api/mes/trace-cross-region';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -9,12 +10,16 @@
import { Col, Row, message } from 'ant-design-vue';
import { getBagCodeHomeSummary } from '#/api/mes/bag-code-home';
import { getTraceCrossRegionSummary } from '#/api/mes/trace-cross-region';
import { getTraceConsumerScanSummary } from '#/api/mes/trace-consumer-scan';
import { defaultSummary } from './data';
import BagCodeCards from './modules/bag-code-cards.vue';
import AlertPanel from './modules/alert-panel.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 BusinessFlow from './modules/business-flow.vue';
import KpiCards from './modules/kpi-cards.vue';
import ScanRegionChart from './modules/scan-region-chart.vue';
import Shortcuts from './modules/shortcuts.vue';
import TrendChart from './modules/trend-chart.vue';
@@ -22,6 +27,8 @@
const router = useRouter();
const summary = ref<MesBagCodeHomeApi.Summary>(defaultSummary); // é¦–页汇总统计
const crossRegion = ref<MesTraceCrossRegionApi.Summary>({}); // çªœè´§é¢„警汇总
const scanTotal = ref(0); // ç´¯è®¡æ‰«ç æ¬¡æ•°
/** è·³è½¬åˆ°ç›®æ ‡é¡µé¢ï¼ˆæŒ‰è·¯ç”± name,未加载时提示) */
async function handleNavigate(name: string) {
@@ -32,9 +39,12 @@
  }
}
/** åŠ è½½é¦–é¡µæ±‡æ€»ç»Ÿè®¡ */
/** åŠ è½½é¦–é¡µæ±‡æ€»ç»Ÿè®¡ã€çªœè´§é¢„è­¦æ±‡æ€»ã€æ¶ˆè´¹è€…æ‰«ç æ±‡æ€» */
async function loadSummary() {
  summary.value = await getBagCodeHomeSummary();
  crossRegion.value = await getTraceCrossRegionSummary();
  const scan = await getTraceConsumerScanSummary();
  scanTotal.value = scan.total ?? 0;
}
onMounted(() => {
@@ -44,26 +54,94 @@
<template>
  <Page>
    <!-- ç¬¬ä¸€è¡Œï¼šåŸºç¡€æ•°æ®å¡ç‰‡ -->
    <BasicCards :summary="summary" class="mb-2" @navigate="handleNavigate" />
    <div class="agri-dashboard">
      <!-- ç¬¬ä¸€è¡Œï¼šä¸šåŠ¡å…¨é“¾è·¯ -->
      <BusinessFlow
        :cross-region-total="crossRegion.total ?? 0"
        :scan-total="scanTotal"
        :summary="summary"
        @navigate="handleNavigate"
      />
    <!-- ç¬¬äºŒè¡Œï¼šä¸€è¢‹ä¸€ç å¡ç‰‡ -->
    <BagCodeCards :summary="summary" class="mb-4" @navigate="handleNavigate" />
      <!-- ç¬¬äºŒè¡Œï¼šå…¨é“¾è·¯ KPI æ€»è§ˆ -->
      <KpiCards :scan-total="scanTotal" :summary="summary" @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>
      <!-- ç¬¬ä¸‰è¡Œï¼šè¢‹ç ç”Ÿæˆè¶‹åŠ¿ + æ¶ˆè´¹è€…扫码地区分布 -->
      <Row :gutter="16" class="mb-4">
        <Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4">
          <TrendChart />
        </Col>
        <Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4">
          <ScanRegionChart />
        </Col>
      </Row>
    <!-- ç¬¬å››è¡Œï¼šå¿«æ·å…¥å£ -->
    <Shortcuts @navigate="handleNavigate" />
      <!-- ç¬¬å››è¡Œï¼šæ‰¹æ¬¡/袋码状态分布 + å¾…办与预警 -->
      <Row :gutter="16" class="mb-4">
        <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>
        <Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4">
          <AlertPanel :cross-region="crossRegion" :summary="summary" @navigate="handleNavigate" />
        </Col>
      </Row>
      <!-- ç¬¬äº”行:按业务域分组的快捷入口 -->
      <Shortcuts @navigate="handleNavigate" />
    </div>
  </Page>
</template>
<style lang="scss">
/* å†œä¸šç¿ ç»¿çŽ»ç’ƒæ‹Ÿæ€é¦–é¡µä¸»é¢˜ â€”— å¯¹é½ç™»å½•页 `agri-auth` è‰²è°ƒï¼Œé‡‡ç”¨æ›´æ¸…淡的浅色玻璃与高级渐变 */
.agri-dashboard {
  position: relative;
  padding: 1rem;
  border-radius: 1.25rem;
  background:
    radial-gradient(900px 480px at 8% 0%, rgba(110, 231, 183, 0.42), transparent 55%),
    radial-gradient(800px 420px at 96% 100%, rgba(6, 95, 70, 0.12), transparent 60%),
    linear-gradient(160deg, #f0fdf9 0%, #dcf7ea 50%, #c7f0d9 100%);
  isolation: isolate;
  color: #0f5c49;
  /* é«˜çº§ç™½çŽ»ç’ƒå¡ç‰‡ï¼šå†…æè¾¹ + ç£¨ç ‚ + æŸ”和层次阴影 */
  .ant-card {
    color: #135746;
    background: linear-gradient(160deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.55));
    border: 1px solid rgba(16, 185, 129, 0.18);
    box-shadow:
      inset 0 1px 0 rgba(255, 255, 255, 0.8),
      0 12px 30px rgba(6, 78, 59, 0.08);
    backdrop-filter: blur(14px);
  }
  .ant-card-head {
    border-bottom-color: rgba(16, 185, 129, 0.14);
  }
  .ant-card-head-title,
  .ant-card-head-title .anticon {
    color: #0f766e;
  }
  .ant-card:hover {
    box-shadow:
      inset 0 1px 0 rgba(255, 255, 255, 0.9),
      0 18px 44px rgba(6, 78, 59, 0.14);
    border-color: rgba(16, 185, 129, 0.34);
  }
  /* è¦†ç›– App æµ…色主题的语义色:统一为柔和墨绿 */
  .text-muted-foreground {
    color: rgba(15, 78, 60, 0.62);
  }
  .text-2xl.font-bold {
    color: #047857;
  }
}
</style>
src/views/dashboard/bagCodeHome/modules/alert-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
<script lang="ts" setup>
import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home';
import type { MesTraceCrossRegionApi } from '#/api/mes/trace-cross-region';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Badge, Card } from 'ant-design-vue';
defineOptions({ name: 'BagCodeHomeAlertPanel' });
const props = defineProps<{
  summary: MesBagCodeHomeApi.Summary;
  crossRegion: MesTraceCrossRegionApi.Summary;
}>();
const emit = defineEmits<{
  navigate: [name: string];
}>();
/** å¾…办与预警列表 */
const alertItems = computed(() => [
  {
    count: props.crossRegion.pending ?? 0,
    desc: '待处理的窜货预警',
    icon: 'lucide:shield-alert',
    iconClass: 'bg-[rgba(239,68,68,0.12)] text-[#ef4444]',
    label: '窜货预警',
    routeName: 'MesTraceCrossRegion',
  },
  {
    count: props.summary.stockWarningPendingCount ?? 0,
    desc: '未处理的库存预警',
    icon: 'lucide:bell-alert',
    iconClass: 'bg-[rgba(245,158,11,0.14)] text-[#f59e0b]',
    label: '库存预警',
    routeName: 'MesWmStockWarning',
  },
  {
    count: props.summary.ncrPendingCount ?? 0,
    desc: '待评审的不合格品单',
    icon: 'lucide:shield-x',
    iconClass: 'bg-[rgba(16,185,129,0.14)] text-[#10b981]',
    label: '不合格品',
    routeName: 'MesQcNcr',
  },
]);
</script>
<template>
  <Card title="待办与预警" class="h-full">
    <div class="flex flex-col">
      <div
        v-for="item in alertItems"
        :key="item.label"
        class="flex cursor-pointer items-center gap-3 border-b border-[rgba(16,185,129,0.16)] px-2 py-4 transition-colors last:border-b-0 hover:bg-[rgba(16,185,129,0.08)]"
        @click="emit('navigate', item.routeName)"
      >
        <div
          class="flex size-10 flex-shrink-0 items-center justify-center rounded-lg"
          :class="item.iconClass"
        >
          <IconifyIcon class="size-5" :icon="item.icon" />
        </div>
        <div class="flex flex-1 flex-col gap-0.5">
          <span class="text-sm font-medium">{{ item.label }}</span>
          <span class="text-muted-foreground text-xs">{{ item.desc }}</span>
        </div>
        <Badge :count="item.count" :show-zero="false" />
      </div>
    </div>
  </Card>
</template>
src/views/dashboard/bagCodeHome/modules/bag-code-cards.vue
ÎļþÒÑɾ³ý
src/views/dashboard/bagCodeHome/modules/basic-cards.vue
ÎļþÒÑɾ³ý
src/views/dashboard/bagCodeHome/modules/business-flow.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
<script lang="ts" setup>
import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Card } from 'ant-design-vue';
defineOptions({ name: 'BagCodeHomeBusinessFlow' });
const props = defineProps<{
  summary: MesBagCodeHomeApi.Summary;
  scanTotal: number; // ç´¯è®¡æ‰«ç æ¬¡æ•°
  crossRegionTotal: number; // çªœè´§é¢„警总数
}>();
const emit = defineEmits<{
  navigate: [name: string];
}>();
/** æ•°å€¼å…œåº•:字段缺失时按 0 å¤„理,避免 toLocaleString æŠ¥é”™ */
const num = (v?: number) => v ?? 0;
/** å…¨é“¾è·¯ä¸šåŠ¡èŠ‚ç‚¹ï¼šå“ç§ â†’ äº§çº¿ â†’ æ‰¹æ¬¡ â†’ è¢‹ç  â†’ æ‰˜ç›˜ â†’ å‡ºåº“ â†’ æµé€šæ‰«ç  â†’ é˜²çªœæº¯æºï¼ˆç¿ ç»¿ä¸»é¢˜ï¼Œæµé€š/防窜保留语义色) */
const nodes = computed(() => [
  { color: '#10b981', count: num(props.summary.mdmItemCount), icon: 'lucide:package', label: '品种', routeName: 'MdmItem' },
  { color: '#34d399', count: num(props.summary.lineCount), icon: 'lucide:factory', label: '产线', routeName: 'MesProLine' },
  { color: '#0d9488', count: num(props.summary.batchCount), icon: 'lucide:layers', label: '生产批次', routeName: 'MesProBatch' },
  { color: '#059669', count: num(props.summary.bagCodeCount), icon: 'lucide:scan-barcode', label: '袋码', routeName: 'MesProBagCode' },
  { color: '#2dd4bf', count: num(props.summary.palletCount), icon: 'lucide:grid-2x2', label: '托盘码', routeName: 'MesProPallet' },
  { color: '#0f766e', count: num(props.summary.productSalesCount), icon: 'lucide:truck', label: '销售出库', routeName: 'MesWmProductSales' },
  { color: '#f59e0b', count: num(props.scanTotal), icon: 'lucide:scan-line', label: '流通扫码', routeName: 'MesConsumerScanStatistics' },
  { color: '#f43f5e', count: num(props.crossRegionTotal), icon: 'lucide:shield-alert', label: '防窜/溯源', routeName: 'MesTraceCrossRegion' },
]);
</script>
<template>
  <Card class="mb-4">
    <template #title>
      <span class="flex items-center gap-2">
        <IconifyIcon class="size-5" icon="lucide:waypoints" />
        ä¸šåŠ¡å…¨é“¾è·¯
      </span>
    </template>
    <!-- ä»Žå·¦åˆ°å³ä¸²è”每个业务节点,中间用箭头衔接 -->
    <div class="flex flex-col gap-3 lg:flex-row lg:items-stretch">
      <template v-for="(node, index) in nodes" :key="node.label">
        <div
          class="flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-xl border border-[rgba(16,185,129,0.22)] bg-[rgba(255,255,255,0.68)] p-3 backdrop-blur transition-all hover:-translate-y-0.5 hover:bg-[rgba(16,185,129,0.12)] hover:shadow-md"
          @click="emit('navigate', node.routeName)"
        >
          <div
            class="flex size-11 flex-shrink-0 items-center justify-center rounded-xl text-white"
            :style="{ background: node.color }"
          >
            <IconifyIcon class="size-6" :icon="node.icon" />
          </div>
          <div class="flex min-w-0 flex-col gap-0.5">
            <span class="text-muted-foreground truncate text-xs">{{ node.label }}</span>
            <span class="text-lg font-bold leading-tight">{{ node.count.toLocaleString() }}</span>
          </div>
        </div>
        <!-- èŠ‚ç‚¹é—´ç®­å¤´ï¼ˆå°å±éšè—ï¼‰ -->
        <div
          v-if="index < nodes.length - 1"
          class="hidden items-center lg:flex"
        >
          <IconifyIcon class="text-muted-foreground size-4" icon="lucide:chevron-right" />
        </div>
      </template>
    </div>
  </Card>
</template>
src/views/dashboard/bagCodeHome/modules/kpi-cards.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,108 @@
<script lang="ts" setup>
import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { gsap } from 'gsap';
import { IconifyIcon } from '@vben/icons';
import { Card, Col, Row } from 'ant-design-vue';
defineOptions({ name: 'BagCodeHomeKpiCards' });
const props = defineProps<{
  summary: MesBagCodeHomeApi.Summary;
  scanTotal: number; // ç´¯è®¡æ‰«ç æ¬¡æ•°
}>();
const emit = defineEmits<{
  navigate: [name: string];
}>();
/** å…¨é“¾è·¯ KPI å¡ç‰‡ï¼šè¦†ç›–基础数据/生产/一袋一码/出库/流通/仓储/质量(翠绿主题,预警/不合格保留语义色) */
const cards = computed(() => [
  { gradient: 'linear-gradient(135deg, #10b981, #047857)', icon: 'lucide:package', name: '品种总数', routeName: 'MdmItem', sub: '基础数据', value: () => props.summary.mdmItemCount ?? 0 },
  { gradient: 'linear-gradient(135deg, #34d399, #059669)', icon: 'lucide:layers', name: '生产批次', routeName: 'MesProBatch', sub: '一袋一码', value: () => props.summary.batchCount ?? 0 },
  { gradient: 'linear-gradient(135deg, #059669, #047857)', icon: 'lucide:scan-barcode', name: '袋码', routeName: 'MesProBagCode', sub: '赋码', value: () => props.summary.bagCodeCount ?? 0 },
  { gradient: 'linear-gradient(135deg, #6ee7b7, #10b981)', icon: 'lucide:grid-2x2', name: '托盘码', routeName: 'MesProPallet', sub: '托盘', value: () => props.summary.palletCount ?? 0 },
  { gradient: 'linear-gradient(135deg, #0d9488, #0f766e)', icon: 'lucide:truck', name: '销售出库单', routeName: 'MesWmProductSales', sub: '出库流通', value: () => props.summary.productSalesCount ?? 0 },
  { gradient: 'linear-gradient(135deg, #10b981, #0d9488)', icon: 'lucide:scan-line', name: '累计扫码', routeName: 'MesConsumerScanStatistics', sub: '溯源', value: () => props.scanTotal ?? 0 },
  { gradient: 'linear-gradient(135deg, #f59e0b, #b45309)', icon: 'lucide:bell-alert', name: '库存预警', routeName: 'MesWmStockWarning', sub: '仓储', value: () => props.summary.stockWarningPendingCount ?? 0 },
  { gradient: 'linear-gradient(135deg, #f43f5e, #e11d48)', icon: 'lucide:shield-x', name: '待评审不合格', routeName: 'MesQcNcr', sub: '质量', value: () => props.summary.ncrPendingCount ?? 0 },
]);
/** æ¯ä¸ªå¡ç‰‡ç›®æ ‡æ•°å­—ï¼›display ä¸º GSAP è®¡æ•°åŠ¨ç”»è¿‡ç¨‹ä¸­å±•ç¤ºçš„æ–‡æœ¬ */
const targets = computed(() => cards.value.map((card) => card.value()));
const display = ref<string[]>(cards.value.map(() => '0'));
let tweens: gsap.core.Tween[] = [];
/** ç”¨ GSAP æŠŠæ•°å­—从 0 æ»šåŠ¨åˆ°ç›®æ ‡å€¼ï¼ˆåƒåˆ†ä½æ ¼å¼åŒ–ï¼‰ */
function animateTo(to: number[]) {
  tweens.forEach((t) => t.kill());
  tweens = [];
  display.value = to.map(() => '0');
  to.forEach((endVal, index) => {
    const proxy = { v: 0 };
    tweens.push(
      gsap.to(proxy, {
        duration: 1.2,
        ease: 'power2.out',
        onComplete: () => {
          display.value[index] = endVal.toLocaleString();
        },
        onUpdate: () => {
          display.value[index] = Math.round(proxy.v).toLocaleString();
        },
        v: endVal,
      }),
    );
  });
}
onMounted(() => {
  animateTo(targets.value);
});
// æ±‡æ€»æ•°æ®åŠ è½½åŽï¼Œæ•°å­—ç”± 0 æ»šåŠ¨åˆ°çœŸå®žå€¼
watch(targets, (val) => animateTo(val));
onUnmounted(() => {
  tweens.forEach((t) => t.kill());
  tweens = [];
});
</script>
<template>
  <Row :gutter="16">
    <Col
      v-for="(card, index) 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 class="min-w-0">
            <div class="text-muted-foreground mb-1 text-sm">{{ card.name }}</div>
            <div class="text-2xl font-bold leading-tight">{{ display[index] }}</div>
            <div class="text-muted-foreground mt-1 truncate text-xs">{{ card.sub }}</div>
          </div>
        </div>
      </Card>
    </Col>
  </Row>
</template>
src/views/dashboard/bagCodeHome/modules/scan-region-chart.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
<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 } from 'ant-design-vue';
import { getTraceConsumerScanDistribution } from '#/api/mes/trace-consumer-scan';
import { getRegionBarChartOptions } from '../chart-options';
defineOptions({ name: 'BagCodeHomeScanRegionChart' });
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** åŠ è½½æ¶ˆè´¹è€…æ‰«ç åœ°åŒºï¼ˆçœä»½ï¼‰åˆ†å¸ƒ */
async function loadData() {
  const data = await getTraceConsumerScanDistribution('province');
  // å– Top 10 çœä»½ï¼Œé¿å…æ¨ªå‘过长
  const top = data.slice(0, 10).map((d) => ({ count: d.count, name: d.name }));
  await renderEcharts(getRegionBarChartOptions(top));
}
onMounted(() => {
  loadData();
});
</script>
<template>
  <Card title="消费者扫码地区分布" class="h-full">
    <EchartsUI ref="chartRef" class="h-[320px] w-full" />
  </Card>
</template>
src/views/dashboard/bagCodeHome/modules/shortcuts.vue
@@ -9,12 +9,74 @@
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' },
interface Shortcut {
  icon: string;
  name: string;
  routeName: string;
}
interface ShortcutGroup {
  title: string;
  items: Shortcut[];
}
/** æŒ‰ä¸šåŠ¡åŸŸåˆ†ç»„çš„å¿«æ·å…¥å£ */
const groups: ShortcutGroup[] = [
  {
    title: '基础数据',
    items: [
      { icon: 'lucide:package', name: '品种管理', routeName: 'MdmItem' },
      { icon: 'lucide:folder-tree', name: '品种分类', routeName: 'MesMdItemType' },
      { icon: 'lucide:ruler', name: '计量单位', routeName: 'MdmUnit' },
    ],
  },
  {
    title: '一袋一码生产',
    items: [
      { icon: 'lucide:factory', name: '产线维护', routeName: 'MesProLine' },
      { icon: 'lucide:layers', name: '批次生产', routeName: 'MesProBatch' },
      { icon: 'lucide:scan-barcode', name: '赋码管理', routeName: 'MesProBagCode' },
      { icon: 'lucide:grid-2x2', name: '托盘码', routeName: 'MesProPallet' },
      { icon: 'lucide:scan-line', name: '扫码入库', routeName: 'MesProScanIn' },
    ],
  },
  {
    title: '出库流通',
    items: [
      { icon: 'lucide:send', name: '发货通知', routeName: 'MesWmSalesNotice' },
      { icon: 'lucide:truck', name: '销售出库', routeName: 'MesWmProductSales' },
      { icon: 'lucide:rotate-ccw', name: '销售退货', routeName: 'MesWmReturnSales' },
    ],
  },
  {
    title: '防伪防窜货',
    items: [
      { icon: 'lucide:shield-alert', name: '窜货预警', routeName: 'MesTraceCrossRegion' },
      { icon: 'lucide:map-pin', name: '扫码地理分布', routeName: 'MesConsumerScanDistribution' },
      { icon: 'lucide:database-check', name: '完整性校验', routeName: 'MesTraceIntegrity' },
    ],
  },
  {
    title: '溯源分析',
    items: [{ icon: 'lucide:scan-line', name: '消费者扫码统计', routeName: 'MesConsumerScanStatistics' }],
  },
  {
    title: '仓储物流',
    items: [
      { icon: 'lucide:warehouse', name: '仓库设置', routeName: 'MesWmWarehouse' },
      { icon: 'lucide:boxes', name: '库存现有量', routeName: 'MesWmMaterialStock' },
      { icon: 'lucide:bell-alert', name: '库存预警', routeName: 'MesWmStockWarning' },
      { icon: 'lucide:move-right', name: '转移调拨', routeName: 'MesWmTransfer' },
    ],
  },
  {
    title: '质量管理',
    items: [
      { icon: 'lucide:list-tree', name: '批次追溯', routeName: 'BatchTrace' },
      { icon: 'lucide:shield-x', name: '不合格品', routeName: 'MesQcNcr' },
      { icon: 'lucide:bean', name: '种子质检', routeName: 'MesQcSeedQuality' },
    ],
  },
];
/** è·³è½¬åˆ°ç›®æ ‡é¡µé¢ï¼ˆæŒ‰è·¯ç”± name,未加载时提示) */
@@ -30,18 +92,20 @@
<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" />
      <Col v-for="group in groups" :key="group.title" :lg="8" :md="12" :sm="24" :xs="24" class="mb-4">
        <div class="rounded-xl border border-[rgba(16,185,129,0.16)] bg-[rgba(255,255,255,0.55)] p-3 backdrop-blur">
          <div class="text-muted-foreground mb-2 text-xs font-medium">{{ group.title }}</div>
          <div class="flex flex-wrap gap-2">
            <div
              v-for="item in group.items"
              :key="item.name"
              class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-[rgba(16,185,129,0.22)] px-2 py-1.5 text-sm transition-colors hover:bg-[rgba(16,185,129,0.12)]"
              @click="handleNavigate(item.routeName)"
            >
              <IconifyIcon class="size-4 text-[#0d9488]" :icon="item.icon" />
              <span>{{ item.name }}</span>
            </div>
          </div>
          <span class="text-sm">{{ item.name }}</span>
        </div>
      </Col>
    </Row>
src/views/erp/sale/order/index.vue
@@ -19,12 +19,10 @@
  getSaleOrderPage,
  updateSaleOrderStatus,
} from '#/api/erp/sale/order';
import { generateFromSaleOrder } from '#/api/mes/wm/salesnotice';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import SalesNoticeForm from '#/views/wls/salesnotice/modules/form.vue';
/** ERP é”€å”®è®¢å•列表 */
defineOptions({ name: 'ErpSaleOrder' });
@@ -34,11 +32,6 @@
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [SalesNoticeFormModal, salesNoticeFormModalApi] = useVbenModal({
  connectedComponent: SalesNoticeForm,
  destroyOnClose: true,
});
@@ -149,21 +142,6 @@
  }
}
/** ç”Ÿæˆå‘货通知单 */
async function handleGenerateNotice(row: ErpSaleOrderApi.SaleOrder) {
  try {
    const noticeId = await generateFromSaleOrder(row.id!);
    message.success('生成发货通知单成功');
    handleRefresh();
    // ç›´æŽ¥æ‰“开发货通知单详情弹框
    salesNoticeFormModalApi
      .setData({ formType: 'detail', id: noticeId })
      .open();
  } catch (e) {
    console.error(e);
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -219,7 +197,6 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <SalesNoticeFormModal />
    <Grid table-title="销售订单列表">
      <template #toolbar-tools>
        <TableAction
@@ -354,14 +331,6 @@
                title: `确认启用${row.no}吗?`,
                confirm: handleEnable.bind(null, row),
              },
            },
            {
              label: '生成发货通知单',
              type: 'link',
              icon: 'ant-design:file-protect-outlined',
              auth: ['mes:wm-sales-notice:create'],
              ifShow: () => row.status === 20 && row.canCreateSalesNotice,
              onClick: handleGenerateNotice.bind(null, row),
            },
            {
              label: $t('common.delete'),
src/views/mes/pro/scan-in/index.vue
@@ -6,32 +6,76 @@
import { gsap } from 'gsap';
import {
  getLatestScanEvent,
  getScanDashboard,
  getScanEventPage,
  inboundScanEvent,
  type MesProScanEventApi,
} from '#/api/mes/pro/scanEvent';
import { getLineSimpleList, type MesProLineApi } from '#/api/mes/pro/line';
import {
  getWarehouseLocationSimpleList,
  type MesWmWarehouseLocationApi,
} from '#/api/mes/wm/warehouse/location';
import {
  getWarehouseAreaSimpleList,
  type MesWmWarehouseAreaApi,
} from '#/api/mes/wm/warehouse/area';
interface ScanRecord extends MesProScanEventApi.ScanEvent {
  key: number;
}
const code = ref('');
const deviceCode = ref('');
const scanType = ref<MesProScanEventApi.ScanType>('BAG');
const lineCode = ref('');
const locationId = ref<number>();
const areaId = ref<number>();
const lines = ref<MesProLineApi.Line[]>([]);
const locations = ref<MesWmWarehouseLocationApi.WarehouseLocation[]>([]);
const areas = ref<MesWmWarehouseAreaApi.WarehouseArea[]>([]);
const loading = ref(false);
const refreshing = ref(false);
const records = ref<ScanRecord[]>([]);
const dashboard = ref<MesProScanEventApi.Dashboard>({});
let timer: ReturnType<typeof setInterval> | undefined;
/** æ‰«ç ç±»åž‹ä¸­æ–‡å */
const SCAN_TYPE_LABEL: Record<MesProScanEventApi.ScanType, string> = {
  BAG: '袋码',
  PALLET: '托盘码',
  BATCH: '批次码',
};
/** æ‰«ç ç±»åž‹ä¸‹æ‹‰é€‰é¡¹ */
const SCAN_TYPE_OPTIONS = [
  { label: '袋码', value: 'BAG' },
  { label: '托盘码', value: 'PALLET' },
  { label: '批次码', value: 'BATCH' },
] as const;
const latest = computed(() => dashboard.value.latest);
const successCount = computed(() => dashboard.value.success ?? records.value.filter((item) => item.status === 'SUCCESS').length);
const failedCount = computed(() => dashboard.value.failed ?? records.value.filter((item) => item.status === 'FAILED').length);
const isReady = computed(() => Boolean(deviceCode.value.trim() && locationId.value && areaId.value));
const isReady = computed(() => Boolean(locationId.value && areaId.value));
const lineOptions = computed(() =>
  lines.value.map((item) => ({
    label: item.code ? `${item.code} ${item.name ?? ''}`.trim() : (item.name ?? ''),
    value: item.code ?? '',
  })),
);
const locationOptions = computed(() =>
  locations.value.map((item) => ({
    label: item.code ? `${item.code} ${item.name ?? ''}`.trim() : (item.name ?? ''),
    value: item.id ?? 0,
  })),
);
const areaOptions = computed(() =>
  areas.value.map((item) => ({
    label: item.code ? `${item.code} ${item.name ?? ''}`.trim() : (item.name ?? ''),
    value: item.id ?? 0,
  })),
);
const locationName = computed(() => locations.value.find((item) => item.id === locationId.value)?.name ?? '');
const areaName = computed(() => areas.value.find((item) => item.id === areaId.value)?.name ?? '');
const pageRoot = ref<HTMLElement>();
let animationContext: gsap.Context | undefined;
let feedbackTimeline: gsap.core.Timeline | undefined;
@@ -41,7 +85,7 @@
  try {
    const [summary, page] = await Promise.all([
      getScanDashboard(),
      getScanEventPage({ pageNo: 1, pageSize: 20, deviceCode: deviceCode.value.trim() || undefined }),
      getScanEventPage({ pageNo: 1, pageSize: 20 }),
    ]);
    dashboard.value = summary;
    records.value = (page.list ?? []).map((item, index) => ({ ...item, key: item.id ?? index }));
@@ -50,28 +94,57 @@
  }
}
async function loadOptions() {
  try {
    const [lineList, locationList] = await Promise.all([
      getLineSimpleList(),
      getWarehouseLocationSimpleList(),
    ]);
    lines.value = lineList ?? [];
    locations.value = locationList ?? [];
  } catch {
    lines.value = [];
    locations.value = [];
  }
}
async function loadAreas() {
  areas.value = [];
  areaId.value = undefined;
  if (!locationId.value) return;
  try {
    areas.value = (await getWarehouseAreaSimpleList(locationId.value)) ?? [];
  } catch {
    areas.value = [];
  }
}
function onScanTypeChange() {
  code.value = '';
}
async function submitScan() {
  const bagCode = code.value.trim();
  if (!bagCode || !deviceCode.value.trim()) {
    message.warning('请先填写设备编码,再扫描袋码');
  if (!bagCode) {
    message.warning('请先扫描追溯码');
    return;
  }
  if (!locationId.value || !areaId.value) {
    message.warning('请先配置 WIP æš‚存库区和库位');
    message.warning('请先选择 WIP æš‚存库区和库位');
    return;
  }
  loading.value = true;
  try {
    await inboundScanEvent({
      eventId: `${deviceCode.value.trim()}-${Date.now()}`,
      eventId: `${scanType.value}-${Date.now()}`,
      bagCode,
      scanType: scanType.value,
      quantity: 1,
      deviceCode: deviceCode.value.trim(),
      lineCode: lineCode.value.trim() || undefined,
      lineCode: lineCode.value || undefined,
      locationId: locationId.value,
      areaId: areaId.value,
    });
    message.success(`袋码 ${bagCode} å·²å®Œæˆå…¥åº“`);
    message.success(`${SCAN_TYPE_LABEL[scanType.value]} ${bagCode} å·²å®Œæˆå…¥åº“`);
    if (animationContext && pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      feedbackTimeline?.kill();
      feedbackTimeline = gsap.timeline();
@@ -84,8 +157,23 @@
    code.value = '';
    await refresh();
  } catch (error) {
    const response = error as { message?: string; msg?: string };
    message.error(response.message ?? response.msg ?? '扫码入库失败,请查看事件记录');
    const response = error as {
      message?: string;
      msg?: string;
      response?: { data?: { message?: string; msg?: string } };
    };
    const errorMessage =
      response.response?.data?.message ??
      response.response?.data?.msg ??
      response.message ??
      response.msg;
    if (errorMessage && /入库事务数量必须为正数|数量.*为 0|quantity.*positive/i.test(errorMessage)) {
      message.error('入库数量不能为 0,请检查该批次的袋数配置');
    } else if (!errorMessage || /UnexpectedRollbackException|系统异常|Internal Server Error/i.test(errorMessage)) {
      message.error('扫码入库失败,请检查批次数量和库位配置');
    } else {
      message.error(errorMessage);
    }
    await refresh();
  } finally {
    loading.value = false;
@@ -93,7 +181,6 @@
}
async function refreshLatest() {
  if (deviceCode.value.trim()) await getLatestScanEvent(deviceCode.value.trim());
  await refresh();
}
@@ -128,6 +215,7 @@
      }
    }, pageRoot.value);
  }
  await loadOptions();
  await refresh();
  timer = setInterval(refreshLatest, 15000);
});
@@ -157,30 +245,32 @@
      <div class="metric-card"><div class="metric-icon blue"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><span>累计扫码事件</span><strong>{{ dashboard.total ?? 0 }}</strong><small>持续接收</small></div></div>
      <div class="metric-card"><div class="metric-icon green"><IconifyIcon icon="ant-design:check-circle-outlined" /></div><div><span>成功入库</span><strong>{{ successCount }}</strong><small>已写入库存事务</small></div></div>
      <div class="metric-card"><div class="metric-icon red"><IconifyIcon icon="ant-design:warning-outlined" /></div><div><span>失败事件</span><strong>{{ failedCount }}</strong><small>需关注异常记录</small></div></div>
      <div class="metric-card"><div class="metric-icon orange"><IconifyIcon icon="ant-design:inbox-outlined" /></div><div><span>最近入库袋码</span><strong class="text-value">{{ latest?.bagCode ?? '-' }}</strong><small>{{ latest?.eventTime ?? '暂无记录' }}</small></div></div>
      <div class="metric-card"><div class="metric-icon orange"><IconifyIcon icon="ant-design:inbox-outlined" /></div><div><span>最近入库追溯码</span><strong class="text-value">{{ latest?.bagCode ?? '-' }}</strong><small>{{ latest?.eventTime ?? '暂无记录' }}</small></div></div>
    </section>
    <section class="main-grid">
      <a-card class="scan-card" :bordered="false">
        <template #title><div class="card-title"><IconifyIcon icon="ant-design:scan-outlined" />扫码入库 <a-tag color="blue">自动模式</a-tag></div></template>
        <div class="scan-panel">
          <div class="scan-intro"><div class="scan-ring"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><h2>等待扫码</h2><p>将袋码对准工业相机,或使用扫码枪扫描后回车确认</p></div></div>
          <a-input-search v-model:value="code" class="scan-input" size="large" enter-button="确认入库" :loading="loading" :disabled="loading" placeholder="请扫描袋码,扫码枪回车后自动入库" @search="submitScan" />
          <div class="scan-hint"><IconifyIcon icon="ant-design:clock-circle-outlined" /> æœåŠ¡ç«¯è®°å½•å‡†ç¡®å…¥åº“æ—¶é—´ <span>·</span> <IconifyIcon icon="ant-design:inbox-outlined" /> æ¯è¢‹æ•°é‡æŒ‰ 1 è®¡</div>
          <div class="scan-intro"><div class="scan-ring"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><h2>等待扫码</h2><p>选择追溯码类型后扫码,或使用扫码枪扫描后回车确认</p></div></div>
          <div class="scan-row">
            <a-select v-model:value="scanType" class="scan-type" :options="SCAN_TYPE_OPTIONS" size="large" @change="onScanTypeChange" />
            <a-input-search v-model:value="code" class="scan-input" size="large" enter-button="确认入库" :loading="loading" :disabled="loading" placeholder="请扫描追溯码,扫码枪回车后自动入库" @search="submitScan" />
          </div>
          <div class="scan-hint"><IconifyIcon icon="ant-design:clock-circle-outlined" /> æœåŠ¡ç«¯è®°å½•å‡†ç¡®å…¥åº“æ—¶é—´ <span>·</span> <IconifyIcon icon="ant-design:inbox-outlined" /> è¢‹ç æŒ‰ 1 è¢‹ã€æ‰˜ç›˜æŒ‰å·²è£…袋数、批次按批次袋数入库</div>
        </div>
        <a-divider />
        <a-form layout="vertical" class="config-form">
          <a-form-item label="设备编码" required><a-input v-model:value="deviceCode" prefix="设备" placeholder="例如:CAM-LINE-01" /></a-form-item>
          <a-form-item label="产线编码"><a-input v-model:value="lineCode" placeholder="例如:LINE-A" /></a-form-item>
          <a-form-item label="WIP æš‚存库区" required><a-input-number v-model:value="locationId" class="full-width" :min="1" placeholder="库区编号" /></a-form-item>
          <a-form-item label="WIP æš‚存库位" required><a-input-number v-model:value="areaId" class="full-width" :min="1" placeholder="库位编号" /></a-form-item>
          <a-form-item label="生产产线"><a-select v-model:value="lineCode" :options="lineOptions" allow-clear show-search option-filter-prop="label" placeholder="请选择产线" /></a-form-item>
          <a-form-item label="WIP æš‚存库区" required><a-select v-model:value="locationId" :options="locationOptions" show-search option-filter-prop="label" placeholder="请选择库区" @change="loadAreas" /></a-form-item>
          <a-form-item label="WIP æš‚存库位" required><a-select v-model:value="areaId" :options="areaOptions" show-search option-filter-prop="label" :disabled="!locationId" placeholder="请选择库位" /></a-form-item>
        </a-form>
      </a-card>
      <a-card class="status-card" :bordered="false">
        <template #title><div class="card-title"><ApiOutlined />采集设备状态</div></template>
        <div class="device-state"><span class="online-badge"><span class="status-dot" aria-hidden="true" /><span>{{ isReady ? '设备已就绪' : '等待配置' }}</span></span><span class="muted">15 ç§’自动刷新</span></div>
        <div class="status-list"><div><span>当前设备</span><strong>{{ deviceCode || '未配置' }}</strong></div><div><span>生产产线</span><strong>{{ lineCode || '未配置' }}</strong></div><div><span>暂存位置</span><strong>{{ locationId && areaId ? `${locationId} åŒº / ${areaId} ä½` : '未配置' }}</strong></div><div><span>最近入库时间</span><strong>{{ latest?.eventTime || '-' }}</strong></div><div><span>最近生产批次</span><strong>{{ latest?.batchCode || '-' }}</strong></div></div>
        <div class="device-state"><span class="online-badge"><span class="status-dot" aria-hidden="true" /><span>{{ isReady ? '位置已就绪' : '等待配置' }}</span></span><span class="muted">15 ç§’自动刷新</span></div>
        <div class="status-list"><div><span>生产产线</span><strong>{{ lineCode || '未配置' }}</strong></div><div><span>暂存位置</span><strong>{{ locationId && areaId ? `${locationName || locationId} / ${areaName || areaId}` : '未配置' }}</strong></div><div><span>最近入库时间</span><strong>{{ latest?.eventTime || '-' }}</strong></div><div><span>最近生产批次</span><strong>{{ latest?.batchCode || '-' }}</strong></div><div><span>最近扫码类型</span><strong>{{ latest?.scanType ? SCAN_TYPE_LABEL[latest.scanType] : '-' }}</strong></div></div>
        <div class="sync-box"><IconifyIcon icon="ant-design:check-circle-outlined" /><div><strong>仓储系统同步正常</strong><span>扫码事件与库存事务实时写入</span></div></div>
      </a-card>
    </section>
@@ -188,7 +278,7 @@
    <a-card class="records-card" :bordered="false">
      <template #title><div class="card-title"><IconifyIcon icon="ant-design:clock-circle-outlined" />最近扫码事件 <span class="record-count">最近 20 æ¡</span></div></template>
      <a-table :data-source="records" :pagination="false" :scroll="{ x: 1050 }" row-key="key" size="middle">
        <a-table-column key="bagCode" title="袋码" data-index="bagCode" /><a-table-column key="batchCode" title="生产批次" data-index="batchCode" /><a-table-column key="deviceCode" title="设备 / äº§çº¿"><template #default="{ record }">{{ record.deviceCode || '-' }}<span class="sub-text">{{ record.lineCode || '-' }}</span></template></a-table-column>
        <a-table-column key="bagCode" title="追溯码" data-index="bagCode" /><a-table-column key="scanType" title="类型"><template #default="{ record }">{{ record.scanType ? SCAN_TYPE_LABEL[record.scanType as MesProScanEventApi.ScanType] : '袋码' }}</template></a-table-column><a-table-column key="batchCode" title="生产批次" data-index="batchCode" /><a-table-column key="lineCode" title="产线"><template #default="{ record }">{{ record.lineCode || '-' }}</template></a-table-column>
        <a-table-column key="status" title="状态"><template #default="{ record }"><a-tag :color="record.status === 'SUCCESS' ? 'success' : 'error'">{{ record.status === 'SUCCESS' ? '成功入库' : '失败' }}</a-tag></template></a-table-column><a-table-column key="errorCode" title="错误码" data-index="errorCode" /><a-table-column key="errorMessage" title="失败原因" data-index="errorMessage" /><a-table-column key="eventTime" title="处理时间" data-index="eventTime" /><a-table-column key="transactionId" title="库存事务" data-index="transactionId" />
      </a-table>
    </a-card>
@@ -203,8 +293,8 @@
.header-status, .device-state { display: flex; align-items: center; gap: 10px; color: #16835b; font-size: 13px; font-weight: 600; }.header-status :deep(.ant-btn) { color: #607089; }
.status-dot { display: inline-block; flex: 0 0 8px; width: 8px; height: 8px; margin: 0 2px 0 1px; border-radius: 50%; background: #20b878; box-shadow: 0 0 0 4px #d9f7e9; transform-origin: center; }
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 18px; }.metric-card { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px; background: #fff; border: 1px solid #e8edf5; border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.metric-card > div:last-child { min-width: 0; padding-top: 1px; }.metric-icon { flex: 0 0 42px; display: grid; place-items: center; width: 42px; height: 42px; margin-top: 1px; border-radius: 9px; font-size: 20px; }.metric-icon.blue { color: #1677ff; background: #eaf3ff; }.metric-icon.green { color: #17a673; background: #e6f8f0; }.metric-icon.red { color: #e05252; background: #fff0f0; }.metric-icon.orange { color: #d58a17; background: #fff6df; }.metric-card span, .metric-card small { display: block; color: #7b8799; font-size: 12px; }.metric-card strong { display: block; margin: 4px 0; font-size: 25px; line-height: 1.1; }.metric-card .text-value { overflow: hidden; max-width: 180px; text-overflow: ellipsis; white-space: nowrap; font-size: 18px; }
.main-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .8fr); gap: 18px; margin-bottom: 18px; }.scan-card, .status-card, .records-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 10px; min-height: 24px; font-weight: 700; }.card-title :deep(.iconify) { flex: 0 0 auto; margin-top: 1px; }.card-title :deep(.anticon) { color: #1677ff; }.card-title .ant-tag { margin-left: 4px; font-weight: 500; }.scan-panel { padding: 8px 0 4px; }.scan-intro { display: flex; align-items: center; gap: 16px; margin: 4px 0 22px; }.scan-ring { display: grid; place-items: center; width: 58px; height: 58px; margin: 4px 2px 4px 4px; border: 1px solid #b9d5ff; border-radius: 50%; color: #1677ff; background: #f1f7ff; font-size: 25px; }.scan-intro h2 { margin: 0 0 7px; font-size: 19px; line-height: 1.35; }.scan-intro p { margin: 0; color: #7b8799; font-size: 13px; }.scan-input :deep(input) { height: 50px; font-size: 16px; }.scan-input :deep(.ant-input-group-addon .ant-btn) { height: 50px; font-weight: 600; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 15px; padding-left: 2px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint span { margin: 0 2px; color: #b7c0cd; }.config-form { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: 18px; }.config-form :deep(.ant-form-item) { margin-bottom: 14px; }.full-width { width: 100%; }
.main-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .8fr); gap: 18px; margin-bottom: 18px; }.scan-card, .status-card, .records-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 10px; min-height: 24px; font-weight: 700; }.card-title :deep(.iconify) { flex: 0 0 auto; margin-top: 1px; }.card-title :deep(.anticon) { color: #1677ff; }.card-title .ant-tag { margin-left: 4px; font-weight: 500; }.scan-panel { padding: 8px 0 4px; }.scan-intro { display: flex; align-items: center; gap: 16px; margin: 4px 0 22px; }.scan-ring { display: grid; place-items: center; width: 58px; height: 58px; margin: 4px 2px 4px 4px; border: 1px solid #b9d5ff; border-radius: 50%; color: #1677ff; background: #f1f7ff; font-size: 25px; }.scan-intro h2 { margin: 0 0 7px; font-size: 19px; line-height: 1.35; }.scan-intro p { margin: 0; color: #7b8799; font-size: 13px; }.scan-row { display: flex; align-items: stretch; gap: 10px; }.scan-type { flex: 0 0 150px; }.scan-type :deep(.ant-select-selector) { height: 50px !important; }.scan-type :deep(.ant-select-selection-item) { line-height: 48px !important; }.scan-input { flex: 1 1 auto; min-width: 0; }.scan-input :deep(input) { height: 50px; font-size: 16px; }.scan-input :deep(.ant-input-group-addon .ant-btn) { height: 50px; font-weight: 600; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 15px; padding-left: 2px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint span { margin: 0 2px; color: #b7c0cd; }.config-form { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: 18px; }.config-form :deep(.ant-form-item) { margin-bottom: 14px; }.full-width { width: 100%; }
.device-state { justify-content: space-between; margin-bottom: 20px; }.online-badge { display: inline-flex; align-items: center; gap: 10px; min-height: 24px; color: #16835b; white-space: nowrap; }.muted, .sub-text { color: #8793a5; font-size: 12px; }.status-list { border-top: 1px solid #edf0f5; }.status-list div { display: flex; justify-content: space-between; gap: 12px; padding: 13px 0; border-bottom: 1px solid #edf0f5; font-size: 13px; }.status-list span { color: #7b8799; }.status-list strong { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }.sync-box { display: flex; gap: 10px; margin-top: 20px; padding: 12px; border-radius: 7px; color: #16835b; background: #effaf5; }.sync-box div { display: flex; flex-direction: column; gap: 3px; }.sync-box span { color: #5f8b76; font-size: 12px; }.record-count { margin-left: 4px; color: #9aa5b5; font-size: 12px; font-weight: 400; }.sub-text { display: block; margin-top: 3px; }
@media (max-width: 900px) { .metric-grid { grid-template-columns: repeat(2, 1fr); }.main-grid { grid-template-columns: 1fr; } }
@media (max-width: 600px) { .scan-page { padding: 14px; }.page-header { display: block; }.header-status { margin-top: 14px; }.metric-grid { grid-template-columns: 1fr; }.config-form { grid-template-columns: 1fr; } h1 { font-size: 23px; } }
@media (max-width: 600px) { .scan-page { padding: 14px; }.page-header { display: block; }.header-status { margin-top: 14px; }.metric-grid { grid-template-columns: 1fr; }.config-form { grid-template-columns: 1fr; }.scan-row { flex-wrap: wrap; }.scan-type { flex-basis: 100%; } h1 { font-size: 23px; } }
</style>
src/views/mes/qc/batchtrace/modules/TraceDecision.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { MesTraceBatchApi } from '#/api/mes/trace/batch';
const props = defineProps<{ detail: MesTraceBatchApi.Detail }>();
const decision = computed(() => props.detail.decision);
const qualityRisk = computed(() => decision.value?.qualityRisk ?? props.detail.quality?.result === 2);
const riskLevel = computed(() => decision.value?.riskLevel || (qualityRisk.value ? 'HIGH' : 'NORMAL'));
const riskText = computed(() => qualityRisk.value ? '高风险' : riskLevel.value === 'NORMAL' ? '正常' : riskLevel.value);
</script>
<template>
  <div class="grid gap-4 p-4 md:grid-cols-3">
    <div class="rounded border p-4">
      <div class="text-muted-foreground text-sm">质量决策</div>
      <div class="mt-2 text-lg font-semibold" :class="qualityRisk ? 'text-red-600' : 'text-green-600'">
        {{ detail.quality?.resultName || '待检验' }}
      </div>
      <div class="text-muted-foreground mt-2 text-sm">{{ detail.quality?.reason || '暂无异常说明' }}</div>
    </div>
    <div class="rounded border p-4">
      <div class="text-muted-foreground text-sm">库存决策</div>
      <div class="mt-2 text-lg font-semibold">入库 {{ decision?.inboundQuantity ?? detail.inbound?.quantity ?? 0 }}</div>
      <div class="text-muted-foreground mt-2 text-sm">已建立销售去向 {{ decision?.salesTargetCount ?? detail.salesTargets?.length ?? 0 }} æ¡</div>
    </div>
    <div class="rounded border p-4">
      <div class="text-muted-foreground text-sm">防伪决策</div>
      <div class="mt-2 text-lg font-semibold">{{ detail.antiFake?.scanCount ?? 0 }} æ¬¡</div>
      <div class="text-muted-foreground mt-2 text-sm">消费者验证次数</div>
    </div>
  </div>
  <div class="mx-4 mb-4 rounded border p-4" :class="qualityRisk ? 'border-red-200 bg-red-50' : 'border-green-200 bg-green-50'">
    <div class="flex items-center justify-between">
      <div class="font-medium">综合风险等级</div>
      <a-tag :color="qualityRisk ? 'red' : riskLevel === 'NORMAL' ? 'green' : 'orange'">{{ riskText }}</a-tag>
    </div>
  </div>
  <div class="mx-4 mb-4 rounded border p-4">
    <div class="mb-3 font-medium">责任链</div>
    <div class="grid gap-2 text-sm md:grid-cols-3">
      <div>责任人:{{ detail.ownerUserName || '未记录' }}</div>
      <div>生产线:{{ detail.lineName || detail.lineCode || '未记录' }}</div>
      <div>生产人员:{{ detail.workers?.length ?? 0 }} äºº</div>
    </div>
  </div>
</template>
src/views/mes/qc/batchtrace/modules/TraceGraph.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
<script lang="ts" setup>
import type { EchartsUIType, EChartsOption } from '@vben/plugins/echarts';
import type { MesTraceBatchApi } from '#/api/mes/trace/batch';
import { nextTick, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{ detail: MesTraceBatchApi.Detail }>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const render = () => renderEcharts(buildOption());
// use-echarts åœ¨ onMounted åŽæ‰å°† isActive ç½®ä¸º true,
// æ•…渲染必须放到 onMounted(immediate watch åœ¨ setup é˜¶æ®µä¼šè¢«é™é»˜ä¸¢å¼ƒï¼‰
onMounted(() => {
  void nextTick(render);
});
watch(() => props.detail, () => void render(), { deep: true });
const buildOption = (): EChartsOption => {
  const nodes: { id: string; name: string; category: number; symbolSize: number }[] = [];
  const links: { source: string; target: string }[] = [];
  const add = (id: string, name: string, category: number, symbolSize = 42) => {
    if (!nodes.some((node) => node.id === id)) nodes.push({ id, name, category, symbolSize });
  };
  const connect = (source: string, target: string) => {
    if (source !== target && !links.some((link) => link.source === source && link.target === target)) links.push({ source, target });
  };
  const root = `batch:${props.detail.batchCode || props.detail.requestCode || 'unknown'}`;
  add(root, props.detail.batchCode || '生产批次', 1, 58);
  (props.detail.purchaseSources ?? []).slice(0, 12).forEach((item, index) => { const id = `source:${index}`; add(id, item.vendorName || item.receiptCode || '采购来源', 0); connect(id, root); });
  if (props.detail.quality) { add('quality', props.detail.quality.resultName || '质量检验', 2); connect(root, 'quality'); }
  add('warehouse', '扫码入库', 3); connect(root, 'warehouse');
  (props.detail.salesTargets ?? []).slice(0, 12).forEach((item, index) => { const id = `sales:${index}`; add(id, item.clientName || item.salesCode || '销售去向', 4); connect('warehouse', id); });
  if (props.detail.antiFake?.scanCount) { add('consumer', `消费者验证\n${props.detail.antiFake.scanCount} æ¬¡`, 5); connect('warehouse', 'consumer'); }
  const series = { type: 'graph' as const, layout: 'force' as const, roam: true, draggable: true, data: nodes, links, categories: ['来源', '生产批次', '质量', '仓储', '销售', '消费'].map((name) => ({ name })), label: { show: true, fontSize: 12 }, force: { repulsion: 260, edgeLength: 110 }, lineStyle: { color: '#94a3b8', curveness: 0.15 } };
  return { tooltip: { trigger: 'item' }, legend: [{ data: ['来源', '生产批次', '质量', '仓储', '销售', '消费'] }], series: [series] };
};
</script>
<template>
  <div class="p-4">
    <EchartsUI ref="chartRef" :height="'420px'" class="w-full rounded border bg-muted/20" />
    <div class="text-muted-foreground mt-2 text-center text-xs">图谱默认展示批次级关系,来源和销售节点各最多 12 æ¡</div>
  </div>
</template>
src/views/mes/qc/batchtrace/modules/TraceStageOverview.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { gsap } from 'gsap';
import type { MesTraceBatchApi } from '#/api/mes/trace/batch';
const props = defineProps<{ detail: MesTraceBatchApi.Detail }>();
const qualityRisk = computed(() => props.detail.decision?.qualityRisk ?? props.detail.quality?.result === 2);
const riskLevel = computed(() => props.detail.decision?.riskLevel || (qualityRisk.value ? 'HIGH' : 'NORMAL'));
const containerRef = ref<HTMLElement>();
let animationContext: gsap.Context | undefined;
onMounted(() => {
  if (!containerRef.value) return;
  animationContext = gsap.context(() => {
    gsap.from('.trace-stage-card', { autoAlpha: 0, y: 12, duration: 0.3, stagger: 0.04, ease: 'power2.out' });
  }, containerRef.value);
});
onUnmounted(() => animationContext?.revert());
</script>
<template>
  <div ref="containerRef" class="grid grid-cols-2 gap-3 p-4 lg:grid-cols-4">
    <div v-for="stage in [
      { name: '来源', value: detail.purchaseSources?.length ? '已建立' : '待补充' },
      { name: '生产批次', value: detail.batchStatusName || '未知' },
      { name: '质量检验', value: detail.quality?.resultName || '待检验' },
      { name: '包装赋码', value: `${detail.bagTotal ?? 0} è¢‹ / ${detail.palletCount ?? 0} æ‰˜` },
      { name: '仓储入库', value: detail.inbound ? '已入库' : '未入库' },
      { name: '销售去向', value: `${detail.salesTargets?.length ?? 0} æ¡` },
      { name: '消费者验证', value: `${detail.antiFake?.scanCount ?? 0} æ¬¡æ‰«ç ` },
      { name: '风险状态', value: qualityRisk ? '质量异常' : riskLevel === 'NORMAL' ? '正常' : riskLevel },
    ]" :key="stage.name" class="trace-stage-card rounded border bg-white p-4 shadow-sm">
      <div class="text-muted-foreground text-sm">{{ stage.name }}</div>
      <div class="mt-2 text-base font-semibold" :class="stage.name === '风险状态' ? qualityRisk ? 'text-red-600' : riskLevel === 'NORMAL' ? 'text-green-600' : 'text-orange-600' : ''">
        {{ stage.value }}
      </div>
    </div>
  </div>
</template>
src/views/mes/qc/batchtrace/modules/TraceTimeline.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { MesTraceBatchApi } from '#/api/mes/trace/batch';
const props = defineProps<{ detail: MesTraceBatchApi.Detail }>();
const formatTime = (value?: string) => value ? new Date(value).toLocaleString() : '未记录';
const timeline = computed(() => {
  if (props.detail.timeline?.length) return props.detail.timeline;
  return [
    ...(props.detail.purchaseSources?.length ? [{ stage: '来源', eventName: '采购来源', status: `${props.detail.purchaseSources.length} æ¡è®°å½•` }] : []),
    { stage: '生产', eventType: 'BATCH_CREATED', eventName: '生产批次建立', eventTime: props.detail.produceDate, status: props.detail.batchStatusName },
    { stage: '包装', eventName: '包装赋码', quantity: props.detail.bagTotal },
    ...(props.detail.inbound ? [{ stage: '仓储', eventType: 'INBOUND', eventName: '仓储入库', eventTime: props.detail.inbound.eventTime, quantity: props.detail.inbound.quantity, status: props.detail.inbound.status }] : []),
    ...(props.detail.salesTargets ?? []).map((item) => ({ stage: '销售', eventType: 'OUTBOUND', eventName: `销售去向 Â· ${item.clientName || '未记录客户'}`, eventTime: item.salesDate, quantity: item.quantity, status: item.qualityStatus == null ? undefined : String(item.qualityStatus) })),
    ...(props.detail.antiFake?.scanCount ? [{ stage: '消费', eventType: 'CONSUMER_SCANNED', eventName: '消费者验证', eventTime: props.detail.antiFake.lastScanTime, quantity: props.detail.antiFake.scanCount, status: `累计扫码 ${props.detail.antiFake.scanCount} æ¬¡` }] : []),
  ];
});
const colorMap: Record<string, string> = { æ¥æº: 'blue', ç”Ÿäº§: 'blue', åŒ…装: 'purple', ä»“储: 'green', é”€å”®: 'orange', æ¶ˆè´¹: 'purple', é£Žé™©: 'red' };
</script>
<template>
  <div class="p-4">
    <a-timeline>
      <a-timeline-item v-for="(item, index) in timeline" :key="`${item.eventType || item.stage}-${index}`" :color="colorMap[item.stage || '生产'] || 'blue'">
        <div class="font-medium">{{ item.eventName || '追溯事件' }}</div>
        <div class="text-muted-foreground text-sm">
          {{ item.stage || '未分类' }} Â· æ•°é‡ï¼š{{ item.quantity ?? '—' }} Â· {{ formatTime(item.eventTime) }}
          <span v-if="item.status"> Â· {{ item.status }}</span>
        </div>
      </a-timeline-item>
      <a-empty v-if="!timeline.length" description="暂无追溯事件" />
    </a-timeline>
  </div>
</template>
src/views/mes/qc/batchtrace/modules/trace-detail.vue
@@ -1,34 +1,29 @@
<script lang="ts" setup>
import type { MesWmBatchApi } from '#/api/mes/wm/batch';
import type { MesTraceBatchApi } from '#/api/mes/trace/batch';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Tabs, Tooltip } from 'ant-design-vue';
import { Descriptions, Spin, Tabs, Tag, message } from 'ant-design-vue';
import { useDescription } from '#/components/description';
import { getBatchTraceDetail } from '#/api/mes/trace/batch';
import { useDetailSchema } from '../data';
import ProcessTraceList from './process-trace-list.vue';
import PurchaseSourceList from './purchase-source-list.vue';
import SalesTargetList from './sales-target-list.vue';
import TraceList from './trace-list.vue';
import TraceDecision from './TraceDecision.vue';
import TraceGraph from './TraceGraph.vue';
import TraceStageOverview from './TraceStageOverview.vue';
import TraceTimeline from './TraceTimeline.vue';
const detailData = ref<MesWmBatchApi.Batch>(); // å½“前批次详情
const subTabsName = ref<
  | 'backward'
  | 'forward'
  | 'purchase'
  | 'sales'
  | 'process'
>('forward'); // å½“前激活的追溯页签
const [Descriptions] = useDescription({
  bordered: true,
  column: 3,
  schema: useDetailSchema(),
const detailData = ref<MesTraceBatchApi.Detail>();
const loading = ref(false);
const activeTab = ref('overview');
const formatDate = (value?: string) => value ? new Date(value).toLocaleString() : '未记录';
const riskLevel = computed(() => {
  if (!detailData.value) return 'NORMAL';
  return detailData.value.decision?.riskLevel || (detailData.value.quality?.result === 2 ? 'HIGH' : 'NORMAL');
});
const riskLabel = computed(() => riskLevel.value === 'HIGH' ? '高风险' : riskLevel.value === 'NORMAL' ? '正常' : '需关注');
const riskColor = computed(() => riskLevel.value === 'HIGH' ? 'red' : riskLevel.value === 'NORMAL' ? 'green' : 'orange');
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen) {
@@ -36,66 +31,71 @@
      detailData.value = undefined;
      return;
    }
    detailData.value = modalApi.getData<MesWmBatchApi.Batch>();
    subTabsName.value = 'forward';
    const row = modalApi.getData<{ code?: string }>();
    if (row?.code) {
      void loadDetail(row.code);
    }
    activeTab.value = 'overview';
  },
});
async function loadDetail(code: string) {
  loading.value = true;
  try {
    detailData.value = await getBatchTraceDetail(code);
  } catch {
    message.error('批次追溯详情加载失败');
  } finally {
    loading.value = false;
  }
}
</script>
<template>
  <Modal
    title="批次追溯"
    class="w-4/5"
    title="全链路批次追溯"
    class="w-11/12"
    :show-cancel-button="false"
    :show-confirm-button="false"
  >
    <Descriptions class="mx-4 mt-2" :data="detailData" />
    <Tabs
      v-if="detailData?.code"
      v-model:active-key="subTabsName"
      class="mx-4 mt-4"
      type="card"
    >
      <Tabs.TabPane key="forward">
        <template #tab>
          <Tooltip title="顺着生产流往下游查:该批次被哪些工单消耗后,产出成了哪些成品批次">
            <span>向前追溯</span>
          </Tooltip>
        </template>
        <TraceList :batch-code="detailData.code" direction="forward" />
      </Tabs.TabPane>
      <Tabs.TabPane key="backward">
        <template #tab>
          <Tooltip title="逆着生产流往上游查:生产该批次时,消耗了哪些原料批次">
            <span>向后追溯</span>
          </Tooltip>
        </template>
        <TraceList :batch-code="detailData.code" direction="backward" />
      </Tabs.TabPane>
      <Tabs.TabPane key="purchase">
        <template #tab>
          <Tooltip title="原料侧来源:该批次对应的采购入库单与 IQC æ¥æ–™æ£€éªŒè®°å½•">
            <span>采购来源</span>
          </Tooltip>
        </template>
        <PurchaseSourceList :batch-code="detailData.code" />
      </Tabs.TabPane>
      <Tabs.TabPane key="sales">
        <template #tab>
          <Tooltip title="成品侧去向:该批次对应的销售出库单与 OQC å‡ºåŽ‚æ£€éªŒè®°å½•">
            <span>销售去向</span>
          </Tooltip>
        </template>
        <SalesTargetList :batch-code="detailData.code" />
      </Tabs.TabPane>
      <Tabs.TabPane key="process">
        <template #tab>
          <Tooltip title="各工序的投入/产出事件:该批次在哪些工序被投入或产出,及数量和质量状态">
            <span>工序流转</span>
          </Tooltip>
        </template>
        <ProcessTraceList :batch-code="detailData.code" />
      </Tabs.TabPane>
    </Tabs>
    <Spin :spinning="loading">
      <div v-if="detailData" class="px-2">
        <div class="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-5">
          <div class="rounded border bg-blue-50 px-4 py-3"><div class="text-muted-foreground text-xs">袋码数量</div><div class="mt-1 text-lg font-semibold text-blue-700">{{ detailData.bagTotal ?? 0 }}</div></div>
          <div class="rounded border bg-violet-50 px-4 py-3"><div class="text-muted-foreground text-xs">托盘数量</div><div class="mt-1 text-lg font-semibold text-violet-700">{{ detailData.palletCount ?? 0 }}</div></div>
          <div class="rounded border bg-green-50 px-4 py-3"><div class="text-muted-foreground text-xs">入库数量</div><div class="mt-1 text-lg font-semibold text-green-700">{{ detailData.decision?.inboundQuantity ?? detailData.inbound?.quantity ?? 0 }}</div></div>
          <div class="rounded border bg-orange-50 px-4 py-3"><div class="text-muted-foreground text-xs">销售去向</div><div class="mt-1 text-lg font-semibold text-orange-700">{{ detailData.decision?.salesTargetCount ?? detailData.salesTargets?.length ?? 0 }}</div></div>
          <div class="col-span-2 rounded border px-4 py-3 lg:col-span-1" :class="riskLevel === 'HIGH' ? 'border-red-200 bg-red-50' : riskLevel === 'NORMAL' ? 'border-green-200 bg-green-50' : 'border-orange-200 bg-orange-50'"><div class="text-muted-foreground text-xs">综合风险</div><div class="mt-1"><Tag :color="riskColor">{{ riskLabel }}</Tag></div></div>
        </div>
        <Descriptions bordered :column="{ xs: 1, sm: 2, md: 3 }" size="small">
          <Descriptions.Item label="追溯入口">{{ detailData.requestCode || '未记录' }}</Descriptions.Item>
          <Descriptions.Item label="入口类型"><Tag color="blue">{{ detailData.sourceType || 'BATCH' }}</Tag></Descriptions.Item>
          <Descriptions.Item label="批次状态">{{ detailData.batchStatusName || '未知' }}</Descriptions.Item>
          <Descriptions.Item label="品种">{{ detailData.itemName || detailData.itemCode || '未记录' }}</Descriptions.Item>
          <Descriptions.Item label="批次号">{{ detailData.batchCode || '未记录' }}</Descriptions.Item>
          <Descriptions.Item label="责任人">{{ detailData.ownerUserName || '未记录' }}</Descriptions.Item>
          <Descriptions.Item label="生产线">{{ detailData.lineName || detailData.lineCode || '未记录' }}</Descriptions.Item>
          <Descriptions.Item label="计划数量">{{ detailData.planQuantity ?? 0 }}</Descriptions.Item>
          <Descriptions.Item label="生产日期">{{ formatDate(detailData.produceDate) }}</Descriptions.Item>
          <Descriptions.Item label="入库时间">{{ formatDate(detailData.inboundTime || detailData.inbound?.eventTime) }}</Descriptions.Item>
        </Descriptions>
        <Tabs v-model:active-key="activeTab" class="mt-4" type="card">
          <Tabs.TabPane key="overview" tab="阶段总览">
            <TraceStageOverview :detail="detailData" />
          </Tabs.TabPane>
          <Tabs.TabPane key="timeline" tab="全链路时间线">
            <TraceTimeline :detail="detailData" />
          </Tabs.TabPane>
          <Tabs.TabPane key="graph" tab="流动图谱">
            <TraceGraph :detail="detailData" />
          </Tabs.TabPane>
          <Tabs.TabPane key="decision" tab="决策分析">
            <TraceDecision :detail="detailData" />
          </Tabs.TabPane>
        </Tabs>
      </div>
      <div v-else-if="!loading" class="py-12 text-center text-muted-foreground">暂无追溯数据</div>
    </Spin>
  </Modal>
</template>
src/views/mes/qc/seedQuality/index.vue
@@ -3,14 +3,12 @@
import { message, Modal } from 'ant-design-vue';
import { IconifyIcon } from '@vben/icons';
import { MesWmWarehouseTypeEnum } from '@vben/constants';
import { gsap } from 'gsap';
import MdItemSelect from '#/views/mes/md/item/components/select.vue';
import {
  submitSeedQuality,
  type MesQcSeedQualityApi,
} from '#/api/mes/qc/seedQuality';
import type { MesWmWarehouseApi } from '#/api/mes/wm/warehouse';
import { submitSeedQuality } from '#/api/mes/qc/seedQuality';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
interface IndicatorForm {
  code: string;
@@ -23,12 +21,15 @@
const pageRoot = ref<HTMLElement>();
const code = ref('');
const itemId = ref<number>();
const batchCode = ref('');
const reason = ref('');
const loading = ref(false);
const submittedId = ref<number>();
let animationContext: gsap.Context | undefined;
/** å¯é€‰åˆæ ¼ä»“库(仓库类型=20 åˆæ ¼åº“) */
const qualifiedWarehouses = ref<MesWmWarehouseApi.Warehouse[]>([]);
const qualifiedWarehouseId = ref<number>();
const indicators = ref<IndicatorForm[]>([
  { code: 'purity', label: '纯度', unit: '%', minValue: 0, maxValue: 100 },
@@ -50,7 +51,6 @@
function resetForm(clearResult = true) {
  code.value = '';
  itemId.value = undefined;
  batchCode.value = '';
  reason.value = '';
  if (clearResult) submittedId.value = undefined;
@@ -62,12 +62,16 @@
}
async function submit() {
  if (!code.value.trim() || !itemId.value) {
    message.warning('请填写袋码并选择物料');
  if (!code.value.trim()) {
    message.warning('请填写袋码');
    return;
  }
  if (!allEntered.value) {
    message.warning('请先填写四项指标的实测值');
    return;
  }
  if (!qualifiedWarehouseId.value) {
    message.warning('请选择入库合格库');
    return;
  }
  if (indicators.value.some((item) => item.minValue === undefined || item.maxValue === undefined || item.minValue > item.maxValue)) {
@@ -98,7 +102,6 @@
  try {
    submittedId.value = await submitSeedQuality({
      code: code.value.trim(),
      itemId: itemId.value,
      batchCode: batchCode.value.trim() || undefined,
      indicators: indicators.value.map(({ code: indicatorCode, value, minValue, maxValue }) => ({
        code: indicatorCode,
@@ -107,6 +110,7 @@
        maxValue: maxValue as number,
      })),
      reason: reason.value.trim() || undefined,
      qualifiedWarehouseId: qualifiedWarehouseId.value,
    });
    message.success(`质检提交成功,质检编号:${submittedId.value}`);
    if (pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
@@ -122,6 +126,14 @@
}
onMounted(async () => {
  try {
    const warehouses = await getWarehouseSimpleList();
    qualifiedWarehouses.value = (warehouses ?? []).filter(
      (item) => item.type === MesWmWarehouseTypeEnum.QUALIFIED,
    );
  } catch {
    qualifiedWarehouses.value = [];
  }
  await nextTick();
  if (!pageRoot.value) return;
  animationContext = gsap.context(() => {
@@ -162,10 +174,16 @@
        <a-form layout="vertical">
          <div class="base-grid">
            <a-form-item label="袋码" required><a-input v-model:value="code" size="large" :disabled="loading" placeholder="请输入或扫描袋码" /></a-form-item>
            <a-form-item label="物料" required>
              <MdItemSelect v-model="itemId" :disabled="loading" size="large" placeholder="请选择物料" />
            </a-form-item>
            <a-form-item label="生产批次"><a-input v-model:value="batchCode" size="large" :disabled="loading" placeholder="请输入批次号(选填)" /></a-form-item>
            <a-form-item label="入库合格库" required class="full-width">
              <a-select
                v-model:value="qualifiedWarehouseId"
                size="large"
                :disabled="loading"
                placeholder="请选择质检合格后入库的合格库"
                :options="qualifiedWarehouses.map((item) => ({ label: `${item.name}(${item.code})`, value: item.id }))"
              />
            </a-form-item>
          </div>
          <a-divider orientation="left">指标结果</a-divider>
@@ -190,7 +208,7 @@
        <template #title><div class="card-title"><IconifyIcon icon="ep:data-analysis" />判定说明</div></template>
        <div class="result-hero" :class="overallPassed ? 'passed' : allEntered ? 'failed-result' : 'pending-result'"><IconifyIcon :icon="overallPassed ? 'ep:circle-check-filled' : allEntered ? 'ep:warning-filled' : 'ep:edit-pen'" /><strong>{{ overallPassed ? '当前结果合格' : allEntered ? '当前存在不合格项' : '等待录入指标' }}</strong><span>{{ allEntered ? '实测值需处于最小值与最大值之间' : `已录入 ${enteredCount} / 4 é¡¹å®žæµ‹å€¼` }}</span></div>
        <div class="rule-list"><div><IconifyIcon icon="ep:check" /><span>四项指标必须全部提交</span></div><div><IconifyIcon icon="ep:check" /><span>边界值按合格处理</span></div><div><IconifyIcon icon="ep:check" /><span>不合格结果需保留原因</span></div><div><IconifyIcon icon="ep:check" /><span>质检员与检验时间由服务端记录</span></div></div>
        <a-alert message="库存流转由后端处理" description="合格品将从暂存库转入正式库存;不合格品将直接清除暂存库存并保留不合格原因。质检员和检验时间由服务端记录,本页面不选择仓库、库区或库位。" type="info" show-icon />
        <a-alert message="库存流转由后端处理" description="合格品将按所选「入库合格库」从暂存库存入该合格库;不合格品将直接清除暂存库存并保留不合格原因。质检员和检验时间由服务端记录。" type="info" show-icon />
        <div v-if="submittedId" class="success-note"><IconifyIcon icon="ep:success-filled" /><span>最近提交质检编号:<strong>{{ submittedId }}</strong></span></div>
      </a-card>
    </section>
@@ -200,9 +218,9 @@
<style scoped>
.quality-page { min-height: 100%; padding: 24px; background: #f4f7fb; color: #172033; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 22px; }.eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; }.page-header h1 { margin: 6px 0; font-size: 28px; }.page-header p { margin: 0; color: #718096; }.header-badge { display: inline-flex; align-items: center; gap: 8px; padding: 9px 13px; border: 1px solid #cfe0fb; border-radius: 7px; color: #1677ff; background: #f0f6ff; font-size: 13px; font-weight: 600; }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 18px; }.summary-card { display: flex; align-items: flex-start; gap: 14px; padding: 17px 19px; border: 1px solid #e8edf5; border-radius: 10px; background: #fff; box-shadow: 0 3px 12px #243b5310; }.summary-card > .iconify { flex: 0 0 38px; width: 38px; height: 38px; padding: 9px; border-radius: 8px; font-size: 20px; }.summary-card.blue > .iconify { color: #1677ff; background: #eaf3ff; }.summary-card.green > .iconify { color: #17a673; background: #e6f8f0; }.summary-card.red > .iconify { color: #e05252; background: #fff0f0; }.summary-card span, .summary-card small { display: block; color: #7b8799; font-size: 12px; }.summary-card strong { display: block; margin: 4px 0; font-size: 23px; }.content-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(310px, .75fr); gap: 18px; }.form-card, .result-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 9px; font-weight: 700; }.card-title :deep(.iconify) { color: #1677ff; }.base-grid { display: grid; grid-template-columns: 1.3fr 1fr 1.2fr; gap: 16px; }.full-width { width: 100%; }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 18px; }.summary-card { display: flex; align-items: flex-start; gap: 14px; padding: 17px 19px; border: 1px solid #e8edf5; border-radius: 10px; background: #fff; box-shadow: 0 3px 12px #243b5310; }.summary-card > .iconify { flex: 0 0 38px; width: 38px; height: 38px; padding: 9px; border-radius: 8px; font-size: 20px; }.summary-card.blue > .iconify { color: #1677ff; background: #eaf3ff; }.summary-card.green > .iconify { color: #17a673; background: #e6f8f0; }.summary-card.red > .iconify { color: #e05252; background: #fff0f0; }.summary-card span, .summary-card small { display: block; color: #7b8799; font-size: 12px; }.summary-card strong { display: block; margin: 4px 0; font-size: 23px; }.content-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(310px, .75fr); gap: 18px; }.form-card, .result-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 9px; font-weight: 700; }.card-title :deep(.iconify) { color: #1677ff; }.base-grid { display: grid; grid-template-columns: 1.3fr 1.2fr; gap: 16px; }.full-width { width: 100%; }
.indicator-list { display: flex; flex-direction: column; gap: 10px; }.indicator-row { display: flex; align-items: center; gap: 9px; padding: 11px 12px; border: 1px solid #e8edf5; border-radius: 7px; background: #fbfcfe; }.indicator-row.failed { border-color: #ffd6d6; background: #fffafa; }.indicator-name { display: flex; align-items: center; gap: 8px; min-width: 82px; }.indicator-name small { color: #8793a5; font-size: 12px; }.indicator-dot { width: 8px; height: 8px; border-radius: 50%; }.indicator-dot.pass { background: #20b878; }.indicator-dot.fail { background: #e05252; }.indicator-input { width: 105px; }.range-input { width: 88px; }.range-separator { margin-left: auto; color: #8793a5; font-size: 12px; }.reason-item { margin-top: 18px; }.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 5px; }.form-actions :deep(.iconify) { margin-right: 5px; }
.result-hero { display: flex; flex-direction: column; align-items: center; gap: 8px; margin: 8px 0 22px; padding: 25px 15px; border-radius: 8px; text-align: center; }.result-hero :deep(.iconify) { font-size: 35px; }.result-hero strong { font-size: 18px; }.result-hero span { color: #718096; font-size: 12px; }.result-hero.passed { color: #16835b; background: #effaf5; }.result-hero.failed-result { color: #c53e3e; background: #fff3f3; }.result-hero.pending-result { color: #1677ff; background: #f0f6ff; }.rule-list { display: flex; flex-direction: column; gap: 14px; margin-bottom: 22px; }.rule-list div { display: flex; align-items: center; gap: 9px; color: #59677b; font-size: 13px; }.rule-list :deep(.iconify) { color: #20b878; }.success-note { display: flex; align-items: center; gap: 8px; margin-top: 16px; padding: 11px 12px; border-radius: 7px; color: #16835b; background: #effaf5; font-size: 13px; }
@media (max-width: 1050px) { .content-grid { grid-template-columns: 1fr; }.base-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 1050px) { .content-grid { grid-template-columns: 1fr; }.base-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 760px) { .quality-page { padding: 14px; }.page-header { display: block; }.header-badge { margin-top: 14px; }.summary-grid, .base-grid { grid-template-columns: 1fr; }.indicator-row { flex-wrap: wrap; }.range-separator { margin-left: 0; }.indicator-input { flex: 1; min-width: 105px; }.range-input { flex: 1; min-width: 75px; } }
</style>
src/views/mes/qc/seedQuality/record.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,284 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import type { TablePaginationConfig } from 'ant-design-vue';
import { IconifyIcon } from '@vben/icons';
import {
  getSeedQualityPage,
  type SeedQualityRecord,
} from '#/api/mes/qc/seedQuality';
const records = ref<SeedQualityRecord[]>([]);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
const code = ref('');
const batchCode = ref('');
const result = ref<number>();
const total = ref(0);
const passTotal = ref(0);
const failTotal = ref(0);
const processingTotal = computed(() =>
  Math.max(total.value - passTotal.value - failTotal.value, 0),
);
const resultLabel = (value: number) =>
  value === 1 ? '合格' : value === 2 ? '不合格' : '处理中';
const resultColor = (value: number) =>
  value === 1 ? '#16a34a' : value === 2 ? '#dc2626' : '#d97706';
const resultBg = (value: number) =>
  value === 1 ? '#ecfdf3' : value === 2 ? '#fef2f2' : '#fffbeb';
const formatDateTime = (value?: string) =>
  value ? new Date(value).toLocaleString() : '—';
async function loadRecords() {
  loading.value = true;
  try {
    const data = await getSeedQualityPage({
      pageNo: page.value,
      pageSize: pageSize.value,
      code: code.value.trim() || undefined,
      batchCode: batchCode.value.trim() || undefined,
      result: result.value,
    });
    records.value = data.list;
    total.value = data.total;
  } finally {
    loading.value = false;
  }
}
async function loadStats() {
  try {
    const [all, pass, fail] = await Promise.all([
      getSeedQualityPage({ pageNo: 1, pageSize: 1 }),
      getSeedQualityPage({ pageNo: 1, pageSize: 1, result: 1 }),
      getSeedQualityPage({ pageNo: 1, pageSize: 1, result: 2 }),
    ]);
    total.value = all.total;
    passTotal.value = pass.total;
    failTotal.value = fail.total;
  } catch {
    // ç»Ÿè®¡åŠ è½½å¤±è´¥ä¸é˜»å¡žåˆ—è¡¨
  }
}
function searchRecords() {
  page.value = 1;
  void loadRecords();
}
function resetFilters() {
  code.value = '';
  batchCode.value = '';
  result.value = undefined;
  searchRecords();
}
function refreshAll() {
  void Promise.all([loadRecords(), loadStats()]);
}
function handleTableChange(pagination: TablePaginationConfig) {
  page.value = pagination.current || 1;
  pageSize.value = pagination.pageSize || 10;
  void loadRecords();
}
onMounted(refreshAll);
</script>
<template>
  <div class="record-page">
    <!-- é¡µå¤´ -->
    <header class="page-header">
      <div class="header-copy">
        <div class="eyebrow">MES / è´¨é‡ç®¡ç†</div>
        <h1>质检记录</h1>
        <p>按袋码沉淀每一袋种子的检验结论,追踪合格、不合格与待处理结果</p>
      </div>
      <div class="header-badge">
        <span class="badge-pulse" />
        å®žæ—¶æ±‡æ€»
      </div>
    </header>
    <!-- ç»“果统计 -->
    <section class="stat-grid" aria-label="质检结果汇总">
      <div class="stat-card total">
        <div class="stat-icon"><IconifyIcon icon="ep:document" /></div>
        <div class="stat-main"><span>记录总数</span><strong>{{ total.toLocaleString() }}</strong><small>按袋码计</small></div>
      </div>
      <div class="stat-card pass">
        <div class="stat-icon"><IconifyIcon icon="ep:success-filled" /></div>
        <div class="stat-main"><span>质检合格</span><strong>{{ passTotal.toLocaleString() }}</strong><small>可正常入合格库</small></div>
      </div>
      <div class="stat-card fail">
        <div class="stat-icon"><IconifyIcon icon="ep:warning-filled" /></div>
        <div class="stat-main"><span>质检不合格</span><strong>{{ failTotal.toLocaleString() }}</strong><small>已标记报废</small></div>
      </div>
      <div class="stat-card pending">
        <div class="stat-icon"><IconifyIcon icon="ep:clock" /></div>
        <div class="stat-main"><span>处理中</span><strong>{{ processingTotal.toLocaleString() }}</strong><small>待定结论</small></div>
      </div>
    </section>
    <!-- è®°å½•列表 -->
    <section class="record-card">
      <div class="card-toolbar">
        <div class="card-title">
          <IconifyIcon icon="ep:list" />
          æ˜Žç»†åˆ—表
          <span class="count-chip">{{ total.toLocaleString() }} æ¡</span>
        </div>
        <div class="filters">
          <a-input v-model:value="code" allow-clear placeholder="袋码" @press-enter="searchRecords">
            <template #prefix><IconifyIcon icon="ep:box" /></template>
          </a-input>
          <a-input v-model:value="batchCode" allow-clear placeholder="生产批次" @press-enter="searchRecords">
            <template #prefix><IconifyIcon icon="ep:collection-tag" /></template>
          </a-input>
          <a-select
            v-model:value="result"
            allow-clear
            placeholder="检验结果"
            :options="[
              { label: '合格', value: 1 },
              { label: '不合格', value: 2 },
              { label: '处理中', value: 0 },
            ]"
          />
          <a-button type="primary" @click="searchRecords"><IconifyIcon icon="ep:search" />查询</a-button>
          <a-button @click="resetFilters"><IconifyIcon icon="ep:refresh-left" />重置</a-button>
          <a-tooltip title="刷新数据">
            <a-button :loading="loading" aria-label="刷新数据" @click="refreshAll"><IconifyIcon icon="ep:refresh" /></a-button>
          </a-tooltip>
        </div>
      </div>
      <a-table
        :data-source="records"
        :loading="loading"
        :scroll="{ x: 1120 }"
        :pagination="{
          current: page,
          pageSize,
          total,
          showSizeChanger: true,
          showTotal: (t: number) => `共 ${t} æ¡`,
        }"
        row-key="id"
        size="middle"
        @change="handleTableChange"
      >
        <template #emptyText>
          <div class="empty-state">
            <IconifyIcon icon="ep:data-board" />
            <p>暂无质检记录</p>
          </div>
        </template>
        <a-table-column key="id" title="质检编号" data-index="id" width="100" align="right">
          <template #default="{ record }"><span class="mono muted">{{ record.id }}</span></template>
        </a-table-column>
        <a-table-column key="code" title="袋码" data-index="code" min-width="220">
          <template #default="{ record }"><span class="mono strong">{{ record.code }}</span></template>
        </a-table-column>
        <a-table-column key="batchCode" title="生产批次" data-index="batchCode" min-width="190">
          <template #default="{ record }">{{ record.batchCode || '—' }}</template>
        </a-table-column>
        <a-table-column key="result" title="检验结果" width="130">
          <template #default="{ record }">
            <span class="result-tag" :style="{ color: resultColor(record.result), background: resultBg(record.result) }">
              <i class="result-dot" :style="{ background: resultColor(record.result) }" />
              {{ resultLabel(record.result) }}
            </span>
          </template>
        </a-table-column>
        <a-table-column key="reason" title="不合格原因" data-index="reason" min-width="220" ellipsis>
          <template #default="{ record }">
            <span v-if="record.reason" class="reason-text">{{ record.reason }}</span>
            <span v-else class="muted">—</span>
          </template>
        </a-table-column>
        <a-table-column key="inspectedAt" title="检验时间" width="190">
          <template #default="{ record }"><span class="mono muted">{{ formatDateTime(record.inspectedAt) }}</span></template>
        </a-table-column>
        <a-table-column key="createTime" title="提交时间" width="190">
          <template #default="{ record }"><span class="mono muted">{{ formatDateTime(record.createTime) }}</span></template>
        </a-table-column>
      </a-table>
    </section>
  </div>
</template>
<style scoped>
.record-page { min-height: 100%; padding: 20px 24px 32px; background: #f4f7fb; color: #172033; }
/* ===== é¡µå¤´ ===== */
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
.header-copy .eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
.header-copy h1 { margin: 6px 0; font-size: 26px; line-height: 1.2; }
.header-copy p { margin: 0; color: #718096; }
.header-badge { display: inline-flex; align-items: center; gap: 8px; padding: 9px 14px; border: 1px solid #cfe0fb; border-radius: 999px; color: #1677ff; background: #f0f6ff; font-size: 13px; font-weight: 600; }
.badge-pulse { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 0 0 rgba(34, 197, 94, .45); animation: pulse 2s infinite; }
@keyframes pulse { 70% { box-shadow: 0 0 0 7px rgba(34, 197, 94, 0); } 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } }
/* ===== ç»“果统计 ===== */
.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
.stat-card { display: flex; align-items: center; gap: 13px; padding: 15px 17px; border: 1px solid #e8edf5; border-radius: 12px; background: #fff; box-shadow: 0 3px 12px #243b5310; }
.stat-icon { flex: 0 0 40px; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; border-radius: 10px; font-size: 21px; }
.stat-icon :deep(.iconify) { font-size: 21px; }
.stat-main span, .stat-main small { display: block; color: #7b8799; font-size: 12px; }
.stat-main small { margin-top: 2px; }
.stat-main strong { display: block; margin: 3px 0 0; font-size: 23px; line-height: 1; font-variant-numeric: tabular-nums; }
.stat-card.total .stat-icon { color: #1677ff; background: #eaf3ff; }
.stat-card.total .stat-main strong { color: #1677ff; }
.stat-card.pass .stat-icon { color: #16a34a; background: #ecfdf3; }
.stat-card.pass .stat-main strong { color: #16a34a; }
.stat-card.fail .stat-icon { color: #dc2626; background: #fef2f2; }
.stat-card.fail .stat-main strong { color: #dc2626; }
.stat-card.pending .stat-icon { color: #d97706; background: #fffbeb; }
.stat-card.pending .stat-main strong { color: #d97706; }
/* ===== æ˜Žç»†å¡ç‰‡ ===== */
.record-card { padding: 6px 16px 16px; border: 1px solid #e8edf5; border-radius: 12px; background: #fff; box-shadow: 0 3px 12px #243b5310; }
.card-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 13px 0 15px; flex-wrap: wrap; border-bottom: 1px dashed #eef1f6; }
.card-title { display: inline-flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; }
.card-title :deep(.iconify) { color: #1677ff; }
.count-chip { padding: 1px 9px; border-radius: 999px; background: #f0f6ff; color: #1677ff; font-size: 12px; font-weight: 600; }
.filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.filters .ant-input { width: 150px; }
.filters .ant-select { width: 128px; }
.filters .ant-btn { display: inline-flex; align-items: center; gap: 5px; }
.filters :deep(.ant-btn .iconify) { font-size: 14px; }
/* ===== å•元格 ===== */
.strong { font-weight: 600; color: #172033; }
.muted { color: #8793a5; }
.mono { font-variant-numeric: tabular-nums; }
.result-tag { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; line-height: 20px; }
.result-dot { width: 6px; height: 6px; border-radius: 50%; }
.reason-text { color: #59677b; }
.empty-state { padding: 34px 0; text-align: center; color: #b6c0cf; }
.empty-state :deep(.iconify) { font-size: 42px; color: #d5dce6; }
.empty-state p { margin: 10px 0 0; }
/* çŠ¶æ€æ„ŸçŸ¥è¡Œï¼šä¸åˆæ ¼è¡Œè½»é«˜äº® */
:deep(.ant-table-row) { transition: background-color .18s ease; }
:deep(.ant-table-row:hover) { background: #f7faff !important; }
/* ===== å“åº”式 ===== */
@media (max-width: 1100px) { .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) {
  .record-page { padding: 14px; }
  .page-header { display: block; }
  .header-badge { margin-top: 12px; }
  .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .card-toolbar { align-items: flex-start; flex-direction: column; }
  .filters .ant-input { width: 100%; flex: 1; }
}
</style>
src/views/wls/materialstock/components/index.ts
@@ -2,3 +2,4 @@
export { default as WmMaterialStockSelect } from './select.vue';
export { default as WmStockAdjustModal } from './stock-adjust-modal.vue';
export { default as WmStockMoveModal } from './move-modal.vue';
export { default as WmStockBagModal } from './stock-bag-modal.vue';
src/views/wls/materialstock/components/select-dialog.vue
@@ -22,6 +22,7 @@
    mdmItemId?: number;
    virtualFilter?: VirtualFilter;
    warehouseId?: number;
    warehouseType?: number;
  }>(),
  {
    batchId: undefined,
@@ -29,6 +30,7 @@
    mdmItemId: undefined,
    virtualFilter: 'exclude',
    warehouseId: undefined,
    warehouseType: undefined,
  },
);
@@ -163,6 +165,7 @@
            itemTypeId: searchItemTypeId.value,
            itemId: props.mdmItemId,
            warehouseId: formValues.warehouseId ?? props.warehouseId,
            warehouseType: props.warehouseType,
            virtualFilter:
              props.virtualFilter === 'all' ? undefined : props.virtualFilter,
          });
src/views/wls/materialstock/components/select.vue
@@ -24,6 +24,7 @@
    placeholder?: string;
    virtualFilter?: 'all' | 'exclude' | 'only';
    warehouseId?: number;
    warehouseType?: number;
  }>(),
  {
    allowClear: true,
@@ -35,6 +36,7 @@
    placeholder: '请选择库存',
    virtualFilter: 'exclude',
    warehouseId: undefined,
    warehouseType: undefined,
  },
);
@@ -156,6 +158,7 @@
    :mdm-item-id="mdmItemId"
    :virtual-filter="virtualFilter"
    :warehouse-id="warehouseId"
    :warehouse-type="warehouseType"
    @selected="handleSelected"
  />
</template>
src/views/wls/materialstock/components/stock-bag-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
<script lang="ts" setup>
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getMaterialStockBagDetails } from '#/api/mes/wm/materialstock';
const stockId = ref(0);
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: [
      { field: 'bagCode', title: '袋码', minWidth: 180 },
      { field: 'batchCode', title: '批次号', minWidth: 150 },
      {
        field: 'stockState',
        title: '库存状态',
        width: 110,
        cellRender: {
          name: 'CellTag',
          props: {
            options: [
              { label: '暂存库', value: 10, color: 'orange' },
              { label: '合格库', value: 20, color: 'green' },
              { label: '已出库', value: 30, color: 'default' },
            ],
          },
        },
      },
      { field: 'quantity', title: '数量', width: 80 },
      { field: 'inboundTime', title: '入库时间', width: 180 },
    ],
    proxyConfig: {
      ajax: {
        query: async ({ page }) => getMaterialStockBagDetails({
          materialStockId: stockId.value,
          pageNo: page.currentPage,
          pageSize: page.pageSize,
        }),
      },
    },
    rowConfig: { keyField: 'id' },
  },
});
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen) {
    if (!isOpen) return;
    const data = modalApi.getData<{ materialStockId?: number }>();
    stockId.value = data?.materialStockId ?? 0;
    gridApi.query();
  },
});
defineExpose({ open: (materialStockId: number) => modalApi.setData({ materialStockId }).open() });
</script>
<template>
  <Modal title="库存袋码明细" class="w-[900px]">
    <Grid />
  </Modal>
</template>
src/views/wls/materialstock/data.ts
@@ -183,6 +183,11 @@
      slots: { default: 'batchCode' },
    },
    {
      field: 'bagCount',
      title: '袋码数',
      width: 90,
    },
    {
      field: 'warehouseName',
      title: '仓库',
      minWidth: 100,
@@ -219,16 +224,12 @@
        },
      },
    },
    ...(onMove
      ? [
          {
            title: '操作',
            width: 80,
            fixed: 'right' as const,
            slots: { default: 'actions' } as const,
          },
        ]
      : []),
    {
      title: '操作',
      width: onMove ? 150 : 90,
      fixed: 'right' as const,
      slots: { default: 'actions' } as const,
    },
  ];
}
src/views/wls/materialstock/index.vue
@@ -17,9 +17,13 @@
} from '#/api/mes/wm/materialstock';
import { $t } from '#/locales';
import { MdItemTypeTree } from '#/views/mes/md/item/type/components';
import { WmBatchDetail } from '#/views/wls/batch/components';
import QrTraceModal from '#/views/mes/pro/components/qr-trace-modal.vue';
import AreaForm from '#/views/wls/warehouse/area/modules/form.vue';
import { WmStockAdjustModal, WmStockMoveModal } from './components';
import {
  WmStockAdjustModal,
  WmStockBagModal,
  WmStockMoveModal,
} from './components';
import { useGridColumns, useGridFormSchema } from './data';
@@ -38,7 +42,20 @@
  destroyOnClose: true,
});
const batchDetailRef = ref<InstanceType<typeof WmBatchDetail>>();
const [QrTraceQueryModal, qrTraceQueryModalApi] = useVbenModal({
  connectedComponent: QrTraceModal,
  destroyOnClose: true,
});
const [StockBagModal, stockBagModalApi] = useVbenModal({
  connectedComponent: WmStockBagModal,
  destroyOnClose: true,
});
function handleOpenStockBags(row: MesWmMaterialStockApi.MaterialStock) {
  if (!row.id) return;
  stockBagModalApi.setData({ materialStockId: row.id }).open();
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
@@ -89,12 +106,12 @@
  gridApi.query();
}
/** æ‰“开批次详情弹窗 */
/** æ‰“开批次追溯弹窗 */
function handleOpenBatchDetail(row: MesWmMaterialStockApi.MaterialStock) {
  if (!row.batchId) {
  if (!row.batchCode) {
    return;
  }
  batchDetailRef.value?.open(row.batchId);
  qrTraceQueryModalApi.setData({ code: row.batchCode }).open();
}
/** å¤„理冻结状态切换 */
@@ -151,8 +168,10 @@
</script>
<template>
  <Page auto-content-height><AreaModal />
    <WmBatchDetail ref="batchDetailRef" />
  <Page auto-content-height>
    <AreaModal />
    <QrTraceQueryModal />
    <StockBagModal />
    <StockAdjustModal @success="handleStockAdjustSuccess" />
    <StockMoveModal @success="handleStockMoveSuccess" />
@@ -212,6 +231,12 @@
            <TableAction
              :actions="[
                {
                  label: '查看袋码',
                  type: 'link',
                  ifShow: row.bagDetailSupported,
                  onClick: handleOpenStockBags.bind(null, row),
                },
                {
                  label: '移库',
                  type: 'link',
                  auth: ['mes:wm-material-stock:move'],
src/views/wls/productsales/data.ts
@@ -13,6 +13,7 @@
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesWmSalesNoticeStatusEnum,
  MesWmWarehouseTypeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
@@ -21,7 +22,6 @@
import { z } from '#/adapter/form';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { ErpSaleOrderSelect } from '#/views/erp/sale/order/components';
import { getRangePickerDefaultProps } from '#/utils';
import { MdItemSelect } from '#/views/mes/md/item/components';
import { WmMaterialStockSelect } from '#/views/wls/materialstock/components';
@@ -125,52 +125,16 @@
      component: markRaw(WmSalesNoticeSelect),
      componentProps: {
        disabled: isHeaderReadonly(formType),
        // é€‰æ‹©å‘货通知单后,自动回填销售订单号、客户、收货人信息
        // é€‰æ‹©å‘货通知单后,自动回填客户、收货人信息
        onChange: async (notice?: MesWmSalesNoticeApi.SalesNotice) => {
          await formApi?.setValues({
            clientId: notice?.clientId,
            contactAddress: notice?.recipientAddress,
            contactName: notice?.recipientName,
            contactTelephone: notice?.recipientTelephone,
            salesOrderCode: notice?.salesOrderCode,
          });
        },
        status: MesWmSalesNoticeStatusEnum.APPROVED,
      },
    },
    {
      fieldName: 'saleOrderId',
      label: '销售订单编号',
      component: markRaw(ErpSaleOrderSelect),
      componentProps: {
        disabled: isHeaderReadonly(formType),
        placeholder: '请选择销售订单',
        onChange: (item: any) => {
          if (formApi) {
            formApi.setFieldValue('salesOrderCode', item?.no ?? '');
            if (item?.customerId) {
              formApi.setFieldValue('clientId', item.customerId);
            }
          }
        },
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['salesOrderCode'],
        if: () => formType === 'create',
      },
    },
    {
      fieldName: 'salesOrderCode',
      label: '销售订单编号',
      component: 'Input',
      componentProps: {
        disabled: true,
        placeholder: '销售订单编号',
      },
      dependencies: {
        triggerFields: [''],
        if: () => formType !== 'create',
      },
    },
    {
@@ -285,15 +249,6 @@
      },
    },
    {
      fieldName: 'salesOrderCode',
      label: '销售订单编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入销售订单编号',
      },
    },
    {
      fieldName: 'clientId',
      label: '客户',
      component: markRaw(CrmCustomerSelect),
@@ -343,11 +298,6 @@
    {
      field: 'noticeCode',
      title: '发货通知单号',
      minWidth: 160,
    },
    {
      field: 'salesOrderCode',
      title: '销售订单编号',
      minWidth: 160,
    },
    {
@@ -651,6 +601,8 @@
      label: '选择库存',
      component: markRaw(WmMaterialStockSelect),
      componentProps: {
        // åªä»Žåˆæ ¼ä»“库(type=20)选择可拣货库存
        warehouseType: MesWmWarehouseTypeEnum.QUALIFIED,
        // é€‰æ‹©åº“存记录后,自动回填仓库/库区/库位/批次/数量
        onChange: async (stock?: MesWmMaterialStockApi.MaterialStock) => {
          await formApi?.setValues({
src/views/wls/returnsales/data.ts
@@ -1,4 +1,3 @@
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmReturnSalesApi } from '#/api/mes/wm/returnsales';
@@ -15,7 +14,6 @@
import { z } from '#/adapter/form';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { ErpSaleOrderSelect } from '#/views/erp/sale/order/components';
import { MdItemSelect } from '#/views/mes/md/item/components';
import { WmBatchSelect } from '#/views/wls/batch/components';
import {
@@ -36,7 +34,6 @@
export function useFormSchema(
  formType: FormType,
  formApi?: VbenFormApi,
  onOrderChange?: (item: Record<string, unknown> | undefined) => void,
): VbenFormSchema[] {
  return [
    {
@@ -88,33 +85,6 @@
        placeholder: '请输入退货单名称',
      },
      rules: 'required',
    },
    {
      fieldName: 'salesOrderCode',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'saleOrderId',
      label: '销售订单号',
      component: markRaw(ErpSaleOrderSelect),
      componentProps: {
        outStatus: 3,
        placeholder: '请选择销售订单',
        onChange: (item: any) => {
          if (formApi) {
            formApi.setFieldValue('salesOrderCode', item?.no ?? '');
            if (item?.customerId) {
              formApi.setFieldValue('clientId', item.customerId);
            }
          }
          onOrderChange?.(item);
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'clientId',
@@ -183,15 +153,6 @@
      },
    },
    {
      fieldName: 'salesOrderCode',
      label: '销售订单号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入销售订单号',
      },
    },
    {
      fieldName: 'clientId',
      label: '客户',
      component: markRaw(CrmCustomerSelect),
@@ -225,11 +186,6 @@
      field: 'name',
      title: '退货单名称',
      minWidth: 150,
    },
    {
      field: 'salesOrderCode',
      title: '销售订单号',
      minWidth: 140,
    },
    {
      field: 'clientCode',
@@ -343,34 +299,15 @@
}
/** é€€è´§å•行新增/修改的表单 */
export function useLineFormSchema(
  clientId?: number,
  salesOrderCode?: string,
  items?: ErpSaleOrderApi.SaleOrderItem[],
  onItemChange?: (item: ErpSaleOrderApi.SaleOrderItem | undefined) => void,
): VbenFormSchema[] {
  const hasItems = items && items.length > 0;
export function useLineFormSchema(clientId?: number): VbenFormSchema[] {
  return [
    {
      fieldName: 'itemId',
      label: '物料',
      component: hasItems ? 'Select' : markRaw(MdItemSelect),
      componentProps: hasItems
        ? {
            placeholder: '请选择物料',
            options: items.map((item) => ({
              label: `${item.productName || ''}(${item.productBarCode || ''})`,
              value: item.productId,
            })),
            onChange: (value: number | undefined) => {
              const selected = items.find((i) => i.productId === value);
              onItemChange?.(selected);
            },
          }
        : {
            placeholder: '请选择物料',
            productIds: [],
          },
      component: markRaw(MdItemSelect),
      componentProps: {
        placeholder: '请选择物料',
      },
      rules: 'selectRequired',
    },
    {
@@ -383,30 +320,6 @@
        placeholder: '请输入退货数量',
        precision: 2,
      },
      dependencies: {
        triggerFields: ['itemId'],
        componentProps: (values) => {
          const selected = items?.find((i) => i.productId === values.itemId);
          return {
            class: '!w-full',
            max: selected?.count ?? undefined,
            min: 0,
            placeholder: '请输入退货数量',
            precision: 2,
          };
        },
        rules: (values) => {
          const selected = items?.find((i) => i.productId === values.itemId);
          const max = selected?.count;
          if (max !== undefined && max !== null) {
            return z
              .number({ message: '请输入退货数量' })
              .min(0)
              .max(max, `可退货数量不能超过 ${max}`);
          }
          return z.number({ message: '请输入退货数量' }).min(0);
        },
      },
      rules: 'required',
    },
    {
@@ -417,7 +330,6 @@
        clientId,
        disabled: true,
        placeholder: '请选择批次',
        salesOrderCode,
      },
      dependencies: {
        triggerFields: ['itemId'],
@@ -426,7 +338,6 @@
          disabled: true,
          itemId: values.itemId,
          placeholder: '请选择批次',
          salesOrderCode,
        }),
      },
    },
src/views/wls/returnsales/modules/form.vue
@@ -19,9 +19,6 @@
  submitReturnSales,
  updateReturnSales,
} from '#/api/mes/wm/returnsales';
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import { getSaleOrder, getSaleOrderPage } from '#/api/erp/sale/order';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
@@ -31,7 +28,6 @@
const formType = ref<FormType>('create');
const formData = ref<MesWmReturnSalesApi.ReturnSales>();
const originalSnapshot = ref(''); // è¡¨å•原始数据快照,用于提交时跳过未变更的保存请求
const saleOrderItems = ref<ErpSaleOrderApi.SaleOrderItem[]>([]); // å½“前销售订单的物料行
const isEditable = computed(() =>
  // æ˜¯å¦ä¸ºç¼–辑模式(可保存)
  ['create', 'update'].includes(formType.value),
@@ -173,38 +169,13 @@
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setState({
      schema: useFormSchema(formType.value, formApi, (item) => {
        if (item?.id) {
          getSaleOrder(item.id as number).then((order) => {
            saleOrderItems.value = (order as ErpSaleOrderApi.SaleOrder)?.items || [];
          });
        } else {
          saleOrderItems.value = [];
        }
      }),
    });
    formApi.setState({ schema: useFormSchema(formType.value, formApi) });
    formApi.setDisabled(!isEditable.value);
    modalApi.setState({ showConfirmButton: isEditable.value });
    if (data?.id) {
      modalApi.lock();
      try {
        formData.value = await getReturnSales(data.id);
        // é€šè¿‡é”€å”®è®¢å•号反查订单ID,确保 ErpSaleOrderSelect èƒ½æ­£ç¡®å›žæ˜¾
        if (formData.value?.salesOrderCode && !formData.value.saleOrderId) {
          const orderPage = await getSaleOrderPage({
            pageNo: 1,
            pageSize: 1,
            no: formData.value.salesOrderCode,
          } as Record<string, unknown> as never);
          const list = (orderPage as Record<string, unknown>)?.list as Array<Record<string, unknown>> | undefined;
          if (list?.length) {
            const orderId = list[0].id as number;
            (formData.value as Record<string, unknown>).saleOrderId = orderId;
            const order = await getSaleOrder(orderId);
            saleOrderItems.value = order?.items || [];
          }
        }
        // è®¾ç½®åˆ° values
        await formApi.setValues(formData.value);
      } finally {
@@ -227,8 +198,6 @@
          :client-id="formData.clientId"
          :form-type="formType"
          :return-id="formData.id"
          :sale-order-items="saleOrderItems"
          :sales-order-code="formData.salesOrderCode"
        />
      </div>
    </template>
src/views/wls/returnsales/modules/line-form.vue
@@ -1,5 +1,4 @@
<script lang="ts" setup>
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import type { MesWmReturnSalesLineApi } from '#/api/mes/wm/returnsales/line';
import { computed, ref } from 'vue';
@@ -9,7 +8,6 @@
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getBatchPage } from '#/api/mes/wm/batch';
import {
  createReturnSalesLine,
  getReturnSalesLine,
@@ -22,7 +20,6 @@
const emit = defineEmits(['success']);
const formData = ref<MesWmReturnSalesLineApi.ReturnSalesLine>();
const returnId = ref<number>(); // æ‰€å±žé€€è´§å•编号
const maxQuantity = ref<number>(); // å½“前选中物料可退货的最大数量
const getTitle = computed(() => {
  return formData.value?.id
@@ -76,39 +73,10 @@
    const data = modalApi.getData<{
      clientId?: number;
      id?: number;
      items?: ErpSaleOrderApi.SaleOrderItem[];
      returnId: number;
      salesOrderCode?: string;
    }>();
    returnId.value = data.returnId;
    maxQuantity.value = undefined;
    formApi.setState({
      schema: useLineFormSchema(
        data.clientId,
        data.salesOrderCode,
        data.items || [],
        async (item) => {
          maxQuantity.value = item?.count;
          // é‡ç½®æ‰¹æ¬¡
          await formApi.setFieldValue('batchId', undefined);
          if (item?.batchCode) {
            try {
              const batchPage = await getBatchPage({
                code: item.batchCode,
                pageNo: 1,
                pageSize: 1,
              });
              const batchList = batchPage?.list;
              if (batchList?.length) {
                await formApi.setFieldValue('batchId', batchList[0].id);
              }
            } catch {
              // æŸ¥è¯¢å¤±è´¥æ—¶å¿½ç•¥
            }
          }
        },
      ),
    });
    formApi.setState({ schema: useLineFormSchema(data.clientId) });
    if (!data.id) {
      return;
    }
@@ -127,11 +95,5 @@
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
    <div
      v-if="maxQuantity !== undefined"
      class="mx-4 mt-2 text-sm text-blue-600"
    >
      å½“前物料可退货数量:<span class="font-bold">{{ maxQuantity }}</span>
    </div>
  </Modal>
</template>
src/views/wls/returnsales/modules/line-list.vue
@@ -1,7 +1,6 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmReturnSalesDetailApi } from '#/api/mes/wm/returnsales/detail';
import type { MesWmReturnSalesLineApi } from '#/api/mes/wm/returnsales/line';
@@ -30,8 +29,6 @@
  clientId?: number;
  formType: FormType;
  returnId: number;
  saleOrderItems?: ErpSaleOrderApi.SaleOrderItem[];
  salesOrderCode?: string;
}>();
const isEditable = computed(() =>
@@ -66,9 +63,7 @@
  lineFormModalApi
    .setData({
      clientId: props.clientId,
      items: props.saleOrderItems,
      returnId: props.returnId,
      salesOrderCode: props.salesOrderCode,
    })
    .open();
}
@@ -79,9 +74,7 @@
    .setData({
      clientId: props.clientId,
      id: row.id,
      items: props.saleOrderItems,
      returnId: props.returnId,
      salesOrderCode: props.salesOrderCode,
    })
    .open();
}
src/views/wls/salesnotice/components/select-dialog.vue
@@ -46,15 +46,6 @@
      },
    },
    {
      fieldName: 'saleOrderCode',
      label: '销售订单编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入销售订单编号',
      },
    },
    {
      fieldName: 'clientId',
      label: '客户',
      component: markRaw(CrmCustomerSelect),
@@ -100,11 +91,6 @@
      field: 'name',
      title: '通知单名称',
      minWidth: 150,
    },
    {
      field: 'saleOrderCode',
      title: '销售订单编号',
      minWidth: 140,
    },
    {
      field: 'clientName',
src/views/wls/salesnotice/components/select.vue
@@ -119,7 +119,6 @@
          <div>编号:{{ selectedItem.code || '-' }}</div>
          <div>名称:{{ selectedItem.name || '-' }}</div>
          <div>客户:{{ selectedItem.clientName || '-' }}</div>
          <div>销售订单:{{ selectedItem.saleOrderCode || '-' }}</div>
        </div>
      </template>
      <Input
src/views/wls/salesnotice/data.ts
@@ -8,6 +8,7 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesWmWarehouseTypeEnum,
} from '@vben/constants';
import { Button } from 'ant-design-vue';
@@ -15,8 +16,11 @@
import { z } from '#/adapter/form';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import { ErpSaleOrderSelect } from '#/views/erp/sale/order/components';
import { MdItemSelect } from '#/views/mes/md/item/components';
import {
  getStockSummaryByBatch,
  getStockSummaryByItem,
  type MesWmMaterialStockApi,
} from '#/api/mes/wm/materialstock';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
@@ -83,26 +87,11 @@
      rules: 'required',
    },
    {
      fieldName: 'saleOrderId',
      label: '销售订单',
      component: markRaw(ErpSaleOrderSelect),
      componentProps: {
        placeholder: '请选择销售订单',
        // é€‰æ‹©é”€å”®è®¢å•后,自动回填客户信息
        onChange: (item: any) => {
          if (item?.customerId && formApi) {
            formApi.setFieldValue('clientId', item.customerId);
          }
        },
      },
    },
    {
      fieldName: 'clientId',
      label: '客户',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        placeholder: '请选择客户',
        disabled: true,
      },
      rules: 'selectRequired',
    },
@@ -177,15 +166,6 @@
      },
    },
    {
      fieldName: 'saleOrderCode',
      label: '销售订单编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入销售订单编号',
      },
    },
    {
      fieldName: 'clientId',
      label: '客户',
      component: markRaw(CrmCustomerSelect),
@@ -209,11 +189,6 @@
      field: 'name',
      title: '通知单名称',
      minWidth: 150,
    },
    {
      field: 'saleOrderCode',
      title: '销售订单编号',
      minWidth: 140,
    },
    {
      field: 'clientName',
@@ -349,15 +324,98 @@
  ];
}
/** é€šçŸ¥å•行新增/修改的表单 */
/** æ­£å¼åº“(合格库库存,stockState = 20)有库存的物料选项 */
export async function loadQualifiedStockItemOptions(): Promise<
  { label: string; value: number }[]
> {
  const list = await getStockSummaryByItem({
    stockState: 20,
    warehouseType: MesWmWarehouseTypeEnum.QUALIFIED,
  });
  return list.map((item: MesWmMaterialStockApi.MaterialStockSummary) => ({
    label: [item.itemName, item.itemCode, `库存 ${item.totalQuantity}`]
      .filter(Boolean)
      .join(' '),
    value: item.itemId as number,
  }));
}
/** æŒ‡å®šç‰©æ–™çš„æ­£å¼åº“存批次选项 */
export async function loadQualifiedStockBatchOptions(
  itemId: number,
): Promise<{ label: string; value: number }[]> {
  const list = await getStockSummaryByBatch({
    stockState: 20,
    itemId,
    warehouseType: MesWmWarehouseTypeEnum.QUALIFIED,
  });
  return list.map((item: MesWmMaterialStockApi.MaterialStockSummary) => ({
    label: [item.batchCode, `库存 ${item.totalQuantity}`].filter(Boolean).join(' '),
    value: item.batchId as number,
  }));
}
/**
 * å¯å‘货数量:所选物料/批次的正式库存(stockState = 20)汇总。
 *
 * æŒ‡å®šæ‰¹æ¬¡æ—¶ç²¾ç¡®åˆ°è¯¥æ‰¹æ¬¡ï¼Œå¦åˆ™è¿”回物料全部正式库存。
 */
export async function loadAvailableQuantity(
  itemId?: number,
  batchId?: number,
): Promise<number> {
  if (!itemId) {
    return 0;
  }
  if (batchId) {
    const list = await getStockSummaryByBatch({
    stockState: 20,
    itemId,
    warehouseType: MesWmWarehouseTypeEnum.QUALIFIED,
  });
    return list.find((item) => item.batchId === batchId)?.totalQuantity ?? 0;
  }
  const list = await getStockSummaryByItem({
    stockState: 20,
    itemId,
    warehouseType: MesWmWarehouseTypeEnum.QUALIFIED,
  });
  return list.find((item) => item.itemId === itemId)?.totalQuantity ?? 0;
}
/**
 * é€šçŸ¥å•行新增/修改的表单。
 *
 * ç‰©æ–™ã€æ‰¹æ¬¡å‡ä»Žæ­£å¼åº“库存(stockState = 20)中选择,
 * æ‰¹æ¬¡é€‰é¡¹éšç‰©æ–™è”动加载,可用库存不足由后端保存时校验。
 */
export function useLineFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'itemId',
      label: '物料',
      component: markRaw(MdItemSelect),
      component: 'Select',
      componentProps: {
        placeholder: '请选择物料',
        allowClear: true,
        filterOption: true,
        optionFilterProp: 'label',
        options: [],
        placeholder: '请选择物料(仅正式库库存)',
        showSearch: true,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'batchId',
      label: '批次',
      component: 'Select',
      componentProps: {
        allowClear: true,
        filterOption: true,
        optionFilterProp: 'label',
        options: [],
        placeholder: '请选择批次(仅正式库库存)',
        showSearch: true,
      },
      rules: 'selectRequired',
    },
@@ -368,17 +426,18 @@
      componentProps: {
        class: '!w-full',
        min: 0.01,
        placeholder: '请输入发货数量',
        placeholder: '请输入发货数量(不可超过正式库存)',
        precision: 2,
      },
      rules: 'required',
    },
    {
      fieldName: 'batchCode',
      label: '批次号',
      fieldName: 'availableQuantity',
      label: '可发货数量',
      component: 'Input',
      componentProps: {
        placeholder: '请输入批次号',
        disabled: true,
        placeholder: '选择物料/批次后自动带出',
      },
    },
    {
src/views/wls/salesnotice/modules/form.vue
@@ -1,7 +1,6 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import type { MesWmSalesNoticeApi } from '#/api/mes/wm/salesnotice';
import { computed, ref } from 'vue';
@@ -18,8 +17,6 @@
  submitSalesNotice,
  updateSalesNotice,
} from '#/api/mes/wm/salesnotice';
import { createSalesNoticeLine } from '#/api/mes/wm/salesnotice/line';
import { getSaleOrderItemListByOrderId } from '#/api/erp/sale/order';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
@@ -110,29 +107,6 @@
      }
      // æ–°å¢žï¼šä¿æŒå¼¹çª—打开,切换到编辑模式
      const id = await createSalesNotice(data);
      // é€‰æ‹©äº†é”€å”®è®¢å•时,自动带入订单产品明细
      if (data.saleOrderId) {
        try {
          const items = await getSaleOrderItemListByOrderId(data.saleOrderId);
          if (items?.length) {
            await Promise.all(items.map((item: ErpSaleOrderApi.SaleOrderItem) =>
              createSalesNoticeLine({
                noticeId: id,
                saleOrderItemId: item.id,
                itemId: item.productId,
                itemCode: item.productBarCode,
                itemName: item.productName,
                unitMeasureName: item.productUnitName,
                quantity: item.count,
                batchCode: item.batchCode,
                oqcCheckFlag: false,
              }),
            ));
          }
        } catch {
          // èŽ·å–è®¢å•æ˜Žç»†å¤±è´¥ä¸å½±å“ä¸»æµç¨‹
        }
      }
      formData.value = {
        ...data,
        id,
src/views/wls/salesnotice/modules/line-form.vue
@@ -15,7 +15,12 @@
} from '#/api/mes/wm/salesnotice/line';
import { $t } from '#/locales';
import { useLineFormSchema } from '../data';
import {
  loadAvailableQuantity,
  loadQualifiedStockBatchOptions,
  loadQualifiedStockItemOptions,
  useLineFormSchema,
} from '../data';
const emit = defineEmits(['success']);
const formData = ref<MesWmSalesNoticeLineApi.SalesNoticeLine>();
@@ -26,6 +31,26 @@
    ? $t('ui.actionTitle.edit', ['发货通知单行'])
    : $t('ui.actionTitle.create', ['发货通知单行']);
});
async function updateBatchOptions(itemId?: number) {
  const batchOptions = itemId
    ? await loadQualifiedStockBatchOptions(itemId)
    : [];
  formApi.updateSchema([
    {
      fieldName: 'batchId',
      componentProps: { options: batchOptions },
    },
  ]);
}
async function refreshAvailableQuantity(itemId?: number, batchId?: number) {
  const quantity = await loadAvailableQuantity(itemId, batchId);
  await formApi.setFieldValue(
    'availableQuantity',
    quantity > 0 ? `${quantity}` : undefined,
  );
}
const [Form, formApi] = useVbenForm({
  commonConfig: {
@@ -38,6 +63,32 @@
  layout: 'horizontal',
  schema: useLineFormSchema(),
  showDefaultActions: false,
  handleValuesChange: async (values, fieldsChanged) => {
    // setValues ç¼–辑回填时 itemId/batchId åŒæ—¶å˜åŒ–,不清批次仅刷新选项
    const itemChanged = fieldsChanged.includes('itemId');
    const batchChanged = fieldsChanged.includes('batchId');
    if (itemChanged && batchChanged) {
      await updateBatchOptions(values.itemId as number | undefined);
      await refreshAvailableQuantity(
        values.itemId as number | undefined,
        values.batchId as number | undefined,
      );
      return;
    }
    if (itemChanged) {
      await formApi.setFieldValue('batchId', undefined);
      await updateBatchOptions(values.itemId as number | undefined);
      await refreshAvailableQuantity(values.itemId as number | undefined);
      return;
    }
    if (batchChanged) {
      await updateBatchOptions(values.itemId as number | undefined);
      await refreshAvailableQuantity(
        values.itemId as number | undefined,
        values.batchId as number | undefined,
      );
    }
  },
  wrapperClass: 'grid-cols-3',
});
@@ -48,9 +99,12 @@
      return;
    }
    modalApi.lock();
    // æäº¤è¡¨å•
    const data =
      (await formApi.getValues()) as MesWmSalesNoticeLineApi.SalesNoticeLine;
    // æäº¤è¡¨å•(可发货数量仅作提示,不提交到后端)
    const values = (await formApi.getValues()) as MesWmSalesNoticeLineApi.SalesNoticeLine & {
      availableQuantity?: unknown;
    };
    delete values.availableQuantity;
    const data = values;
    data.noticeId = noticeId.value;
    try {
      await (formData.value?.id
@@ -60,6 +114,15 @@
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } catch (error) {
      const response = error as {
        response?: { data?: { message?: string; msg?: string } };
      };
      message.error(
        response.response?.data?.message ??
          response.response?.data?.msg ??
          '保存失败,请检查正式库库存是否充足',
      );
    } finally {
      modalApi.unlock();
    }
@@ -72,14 +135,27 @@
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ id?: number; noticeId: number }>();
    noticeId.value = data.noticeId;
    if (!data.id) {
      return;
    }
    modalApi.lock();
    try {
      // ç‰©æ–™é€‰é¡¹ï¼šä»…展示正式库有库存的物料
      const itemOptions = await loadQualifiedStockItemOptions();
      formApi.updateSchema([
        {
          fieldName: 'itemId',
          componentProps: { options: itemOptions },
        },
      ]);
      if (!data.id) {
        return;
      }
      formData.value = await getSalesNoticeLine(data.id);
      // è®¾ç½®åˆ° values
      // è®¾ç½®åˆ° values,联动加载批次选项
      await formApi.setValues(formData.value);
      await updateBatchOptions(formData.value?.itemId);
      await refreshAvailableQuantity(
        formData.value?.itemId,
        formData.value?.batchId,
      );
    } finally {
      modalApi.unlock();
    }
src/views/wls/transaction/data.ts
@@ -177,6 +177,7 @@
      field: 'batchCode',
      title: '批次号',
      width: 120,
      slots: { default: 'batchCode' },
    },
    {
      field: 'quantity',
src/views/wls/transaction/index.vue
@@ -2,14 +2,28 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmTransactionApi } from '#/api/mes/wm/transaction';
import { Page } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { Tag } from 'ant-design-vue';
import { Button, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getTransactionPage } from '#/api/mes/wm/transaction';
import QrTraceModal from '#/views/mes/pro/components/qr-trace-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
const [QrTraceQueryModal, qrTraceQueryModalApi] = useVbenModal({
  connectedComponent: QrTraceModal,
  destroyOnClose: true,
});
function handleBatchClick(row: MesWmTransactionApi.Transaction) {
  if (!row.batchCode) {
    return;
  }
  qrTraceQueryModalApi.setData({ code: row.batchCode }).open();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
@@ -50,7 +64,19 @@
<template>
  <Page auto-content-height>
    <QrTraceQueryModal />
    <Grid table-title="库存事务流水">
      <template #batchCode="{ row }">
        <Button
          v-if="row.batchCode"
          type="link"
          class="!px-0"
          @click="handleBatchClick(row)"
        >
          {{ row.batchCode }}
        </Button>
        <span v-else>-</span>
      </template>
      <template #typeName="{ row }">
        <Tag :color="row.type === 1 ? 'green' : 'red'">
          {{ row.typeName || '-' }}
src/views/wls/warehouse/data.ts
@@ -5,7 +5,7 @@
import { h } from 'vue';
import { DICT_TYPE, MesAutoCodeRuleCode } from '@vben/constants';
import { DICT_TYPE, MesAutoCodeRuleCode, MesWmWarehouseTypeEnum } from '@vben/constants';
import { Button } from 'ant-design-vue';
@@ -101,6 +101,19 @@
      },
    },
    {
      fieldName: 'type',
      label: '仓库类型',
      component: 'Select',
      componentProps: {
        placeholder: '请选择仓库类型',
        options: [
          { label: '暂存库', value: MesWmWarehouseTypeEnum.TEMP },
          { label: '合格库', value: MesWmWarehouseTypeEnum.QUALIFIED },
        ],
      },
      rules: 'required',
    },
    {
      fieldName: 'frozen',
      label: '是否冻结',
      component: 'Switch',
@@ -142,6 +155,19 @@
      componentProps: {
        allowClear: true,
        placeholder: '请输入仓库名称',
      },
    },
    {
      fieldName: 'type',
      label: '仓库类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: [
          { label: '暂存库', value: MesWmWarehouseTypeEnum.TEMP },
          { label: '合格库', value: MesWmWarehouseTypeEnum.QUALIFIED },
        ],
        placeholder: '请选择仓库类型',
      },
    },
    {
@@ -192,6 +218,17 @@
        userList.find((user) => user.id === cellValue)?.nickname ?? '',
    },
    {
      field: 'type',
      title: '仓库类型',
      width: 100,
      formatter: ({ cellValue }) =>
        cellValue === MesWmWarehouseTypeEnum.TEMP
          ? '暂存库'
          : cellValue === MesWmWarehouseTypeEnum.QUALIFIED
            ? '合格库'
            : '',
    },
    {
      field: 'frozen',
      title: '冻结',
      width: 90,