云
2 天以前 78caa769e452f63288743b8997f7b4c21bb06ce5
feat(crm,mes): 扩展客户字段与重构批次号生成逻辑

- 客户实体新增社会信用代码及上级经销商等扩展字段
- 客户查询与保存接口支持新增字段的传入与返回
- 增加客户所属上级经销商不能选择自身的校验逻辑
- 重构生产批次号生成规则为四位年份加三位流水号
- 生产批次支持手动输入批次号或系统自动生成
- 简化工作站仓库层级处理并移除库区库位强校验
- 批次列表接口新增返回关联的正式托盘码列表
- 新增袋码与托盘码的批量扫码内容反查方法
- 更新扫码入库与库存袋码查询相关的接口文档
已添加13个文件
已删除12个文件
已修改79个文件
7391 ■■■■■ 文件已修改
src/api/crm/customer/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/batch/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/scanEvent/index.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/seedQuality/index.ts 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/trace/batch.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/materialstock/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/productsales/detail/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/productsales/index.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/productsales/line/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/transaction/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/warehouse/area/index.ts 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/warehouse/location/index.ts 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/appearance/index.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/warehouse/area/index.ts 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wls/warehouse/location/index.ts 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/auth.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/duplicate-bind.wav 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/duplicate-outbound.wav 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/duplicate-quality.wav 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/duplicate-scan.wav 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/scanVoice.ts 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/authentication/authentication.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/system-appearance.ts 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/authentication/login.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/processInstance/detail/index.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/data.ts 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/detail/data.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/workstation/data.ts 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/index.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/data.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/index.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/modules/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/components/qr-trace-modal.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/pallet/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/pallet/index.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/scan-in/index.vue 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/scanEvent/index.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/SalesTraceDetails.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/batchtrace/modules/TraceTimeline.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/seedQuality/index.vue 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/seedQuality/process-detail.vue 808 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/seedQuality/record-processing.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/seedQuality/record-sales.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/seedQuality/record.vue 606 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/seedRange/index.vue 505 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/home.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/inbound.vue 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/miscissue.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/quality-records.vue 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/quality.vue 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/query.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/returnissue.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/sales.vue 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/scanner.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/stock.vue 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/stocktaking.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/transfer.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/appearance/index.vue 209 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/barcode/data.ts 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/barcode/modules/form.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/itemreceipt/data.ts 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/components/move-modal.vue 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/components/select.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/components/stock-adjust-modal.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/components/stock-bag-modal.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/data.ts 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/index.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/miscissue/data.ts 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/miscissue/modules/line-list.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/miscreceipt/data.ts 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/outsourceissue/data.ts 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/outsourcereceipt/data.ts 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productissue/data.ts 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productreceipt/data.ts 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/data.ts 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/index.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/modules/detail-form.vue 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/modules/detail-list.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/modules/form.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/modules/picking-bags.vue 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnissue/data.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/data.ts 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/index.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/modules/return-quality-form.vue 225 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnvendor/data.ts 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/stocktaking/plan/components/condition-value-input.vue 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/stocktaking/plan/data.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/stocktaking/task/data.ts 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/stocktaking/task/modules/line-list.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transaction/data.ts 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transfer/data.ts 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transfer/modules/line-list.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/area/data.ts 338 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/area/index.vue 213 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/area/modules/form.vue 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/components/area-select.vue 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/components/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/components/location-select.vue 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/location/data.ts 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/location/index.vue 220 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/warehouse/location/modules/form.vue 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/customer/index.ts
@@ -9,6 +9,14 @@
  export interface Customer {
    id: number; // ç¼–号
    name: string; // å®¢æˆ·åç§°
    socialCreditCode?: string; // ç¤¾ä¼šä¿¡ç”¨ä»£ç 
    idCardNo?: string; // èº«ä»½è¯å·
    customerName?: string; // å®¢æˆ·å§“名
    filingSerialNo?: string; // å¤‡æ¡ˆæµæ°´å·
    bankAccountName?: string; // é“¶è¡Œè´¦æˆ·åç§°
    region?: string; // æ‰€å±žåŒºåŸŸ
    parentDealerId?: number; // æ‰€å±žä¸Šçº§ç»é”€å•†ç¼–号
    parentDealerName?: string; // æ‰€å±žä¸Šçº§ç»é”€å•†åç§°
    followUpStatus: boolean; // è·Ÿè¿›çŠ¶æ€
    contactLastTime: Date; // æœ€åŽè·Ÿè¿›æ—¶é—´
    contactLastContent: string; // æœ€åŽè·Ÿè¿›å†…容
src/api/mes/pro/batch/index.ts
@@ -23,7 +23,7 @@
    itemName?: string;
    bindingMode?: 'pallet' | 'tonBag'; // æ‰¹æ¬¡å·²æœ‰ç»‘定方式
    businessCode?: string; // å†…部出入库业务码
    code?: string; // æ‰¹æ¬¡å·ï¼ˆè¡¥å½•正式信息后自动生成)
    code?: string; // æ‰¹æ¬¡å·ï¼ˆæ–°å¢ž/补录可人工输入,留空自动生成;生成后不可修改)
    tempCode?: string; // ä¸´æ—¶æ‰¹æ¬¡ç ï¼ˆé¢„生成二维码阶段使用,扫码追溯兼容)
    lineId?: number; // äº§çº¿ç¼–号(页面展示为「加工单位」)
    lineCode?: string; // äº§çº¿ç¼–码(后端回填,页面已隐藏)
@@ -35,6 +35,7 @@
    ownerUserName?: string; // è´£ä»»äººæ˜µç§°
    bagQuantity?: number; // å·²ç”Ÿæˆè¢‹ç æ•°é‡
    palletQuantity?: number; // å·²ç”Ÿæˆæ‰˜ç›˜ç æ•°é‡
    palletCodes?: string[]; // æ‰¹æ¬¡ä¸‹å…¨éƒ¨æ­£å¼æ‰˜ç›˜ç 
    status?: number; // æ‰¹æ¬¡çŠ¶æ€ï¼ˆå­—å…¸ mes_pro_batch_status)
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
@@ -125,7 +126,7 @@
  return requestClient.put('/mes/pro/batch/fill-info', data);
}
/** æ–°å¢žç”Ÿäº§æ‰¹æ¬¡ï¼ˆè‡ªåŠ¨ç”Ÿæˆæ‰¹æ¬¡å·ï¼‰ */
/** æ–°å¢žç”Ÿäº§æ‰¹æ¬¡ï¼ˆæ‰¹æ¬¡å·å¯äººå·¥è¾“入,留空自动生成) */
export function createBatch(data: MesProBatchApi.Batch) {
  return requestClient.post<number>('/mes/pro/batch/create', data);
}
src/api/mes/pro/scanEvent/index.ts
@@ -12,6 +12,10 @@
    bagCode?: string;
    scanType?: ScanType;
    batchCode?: string;
    /** æ‰€å±žæ‰˜ç›˜ç¼–号(托盘码事件为扫码托盘,袋码事件为所属托盘,批次事件为空) */
    palletId?: number;
    /** æ‰˜ç›˜ç  */
    palletCode?: string;
    quantity?: number;
    deviceCode?: string;
    lineCode?: string;
@@ -31,17 +35,21 @@
  export interface Dashboard {
    latest?: ScanEvent;
    total?: number;
    success?: number;
    failed?: number;
  }
  export interface InboundParams {
    eventId: string;
    bagCode: string;
    scanType: ScanType;
    /** ä¸ä¼ é»˜è®¤æŒ‰è¢‹ç  BAG å¤„理 */
    scanType?: ScanType;
    quantity: number;
    deviceCode?: string;
    lineCode?: string;
    locationId: number;
    areaId: number;
    /** åº“区、库位已不再要求前端选择,可不传 */
    locationId?: number;
    areaId?: number;
    remark?: string;
  }
}
src/api/mes/qc/seedQuality/index.ts
@@ -1,18 +1,54 @@
import { requestClient } from '#/api/request';
export interface SeedIndicatorRange {
  id: number;
  itemId: number;
  indicatorCode: string;
  indicatorName: string;
  unit?: string;
  minValue: number;
  maxValue: number;
  sort?: number;
  status?: number;
  updateTime?: string;
}
export function getSeedIndicatorRanges() {
  return requestClient.get<SeedIndicatorRange[]>('/mes/qc/seed-quality/ranges');
export interface SeedIndicatorRangeSaveParams {
  itemId: number;
  indicatorCode: string;
  indicatorName: string;
  unit?: string;
  minValue: number;
  maxValue: number;
  sort?: number;
  status?: number;
}
export function saveSeedIndicatorRanges(ranges: SeedIndicatorRange[]) {
  return requestClient.put('/mes/qc/seed-quality/ranges', { ranges });
/** äº§å“è´¨æ£€é¡¹é…ç½®åˆ—表(itemId å¿…ä¼ ï¼›keyword/status å¯é€‰ï¼‰ */
export function getSeedIndicatorRanges(params: { itemId: number; keyword?: string; status?: number }) {
  return requestClient.get<SeedIndicatorRange[]>('/mes/qc/seed-quality/ranges', { params });
}
/** äº§å“ç”Ÿæ•ˆçš„质检项:产品配置优先,未配置时回退通用默认项 */
export function getEffectiveSeedIndicatorRanges(itemId: number) {
  return requestClient.get<SeedIndicatorRange[]>('/mes/qc/seed-quality/ranges/effective', { params: { itemId } });
}
export function createSeedIndicatorRange(data: SeedIndicatorRangeSaveParams) {
  return requestClient.post<number>('/mes/qc/seed-quality/ranges', data);
}
export function updateSeedIndicatorRange(id: number, data: SeedIndicatorRangeSaveParams) {
  return requestClient.put(`/mes/qc/seed-quality/ranges/${id}`, data);
}
export function deleteSeedIndicatorRange(id: number) {
  return requestClient.delete(`/mes/qc/seed-quality/ranges/${id}`);
}
/** å°†é€šç”¨é»˜è®¤é¡¹å¤åˆ¶åˆ°æŒ‡å®šäº§å“ï¼Œè¿”回复制条数 */
export function copyDefaultSeedIndicatorRanges(itemId: number) {
  return requestClient.post<number>('/mes/qc/seed-quality/ranges/copy-default', undefined, { params: { itemId } });
}
export interface SeedQualityPageParams {
@@ -20,18 +56,68 @@
  pageSize?: number;
  code?: string;
  batchCode?: string;
  /** ç”Ÿäº§æ‰¹æ¬¡å·ç²¾ç¡®åŒ¹é…ï¼ˆæŒ‰æ‰¹æ¬¡å±•开明细时使用) */
  exactBatchCode?: string;
  /** ä»…查询未绑定生产批次的记录 */
  unbound?: boolean;
  result?: number;
  workflowStatus?: number;
  /** è´¨æ£€çº¿ï¼š1 åŠ å·¥éƒ¨ 2 é”€å”®éƒ¨ 0 æœªåŒºåˆ†ï¼ˆåŽ†å²æ•°æ®ï¼‰ */
  qcLine?: number;
  /** ä¸šåŠ¡ç±»åž‹ï¼š1 ç”Ÿäº§å…¥åº“质检 2 ç»é”€å•†é€€è´§è´¨æ£€ */
  bizType?: number;
}
/** æŒ‰ç”Ÿäº§æ‰¹æ¬¡åˆ†ç»„的质检记录汇总 */
export interface SeedQualityBatchGroup {
  batchId?: number;
  batchCode: string;
  batchLabel: string;
  itemId?: number;
  itemCode?: string;
  itemName?: string;
  lineName?: string;
  produceDate?: string;
  totalCount: number;
  passCount: number;
  failCount: number;
  processingCount: number;
  pendingRetestCount: number;
  pendingProcessReviewCount: number;
  pendingCompanyReviewCount: number;
  releasedCount: number;
  scrappedCount: number;
  lastInspectedAt?: string;
}
export function getSeedQualityBatchPage(params: SeedQualityPageParams) {
  return requestClient.get<{ list: SeedQualityBatchGroup[]; total: number }>('/mes/qc/seed-quality/batch-page', { params });
}
export interface SeedQualityRecord {
  id: number;
  code: string;
  /** è´¨æ£€çº¿ï¼š1 åŠ å·¥éƒ¨ 2 é”€å”®éƒ¨ 0 æœªåŒºåˆ†ï¼ˆåŽ†å²æ•°æ®ï¼‰ */
  qcLine?: number;
  /** ä¸šåŠ¡ç±»åž‹ï¼š1 ç”Ÿäº§å…¥åº“质检 2 ç»é”€å•†é€€è´§è´¨æ£€ */
  bizType?: number;
  palletCode?: string;
  itemId?: number;
  itemCode?: string;
  itemName?: string;
  batchCode?: string;
  result: number;
  workflowStatus: number;
  processInstanceId?: string;
  companyProcessInstanceId?: string;
  returnSalesId?: number;
  returnSalesCode?: string;
  retestResult?: number;
  retestedAt?: string;
  processApprovedAt?: string;
  lineFinishedAt?: string;
  companySubmittedAt?: string;
  companyApprovedAt?: string;
  reason?: string;
  inspectorId?: number;
  inspectedAt?: string;
@@ -39,7 +125,18 @@
}
export interface SeedQualityIndicator {
  indicatorCode: string;
  indicatorName?: string;
  unit?: string;
  value?: number;
  minValue: number;
  maxValue: number;
}
/** äº§å“ç”Ÿæ•ˆçš„质检项(识别扫码对象时返回) */
export interface EffectiveIndicator {
  code: string;
  name: string;
  unit?: string;
  minValue: number;
  maxValue: number;
}
@@ -74,6 +171,8 @@
  code: string;
  scanType: 'PALLET' | 'BATCH';
  batchCode?: string;
  itemId?: number;
  indicators?: EffectiveIndicator[];
  totalCount: number;
  retestIds: number[];
  initialRecheckIds: number[];
@@ -84,11 +183,11 @@
  pendingCompanyIds?: number[];
  pendingCompanyFailIds?: number[];
}
export function resolveSeedRetestScope(code: string, scanType: 'PALLET' | 'BATCH') {
  return requestClient.get<SeedRetestScope>('/mes/qc/seed-quality/retest-scope/resolve', { params: { code, scanType }, suppressErrorMessage: true });
export function resolveSeedRetestScope(code: string, scanType: 'PALLET' | 'BATCH', qcLine: 1 | 2 = 1) {
  return requestClient.get<SeedRetestScope>('/mes/qc/seed-quality/retest-scope/resolve', { params: { code, scanType, qcLine }, suppressErrorMessage: true });
}
export function submitSeedRetestScope(data: {
  code: string; scanType: 'PALLET' | 'BATCH'; workflowStatus: 10 | 50;
  code: string; scanType: 'PALLET' | 'BATCH'; qcLine: 1 | 2; workflowStatus: 10 | 50;
  qualityIds: number[]; indicators: MesQcSeedQualityApi.Indicator[]; reason?: string;
}) {
  return requestClient.post<{ processedCount: number }>('/mes/qc/seed-quality/retest-scope', data, { suppressErrorMessage: true });
@@ -98,6 +197,9 @@
}
export function approveSeedQualityBatch(stage: 'process' | 'company', data: { ids: number[]; approved: boolean; opinion: string }) {
  return requestClient.post(`/mes/qc/seed-quality/${stage}-approve-batch`, data, { suppressErrorMessage: true });
}
export function retrySeedQualityBpm(id: number) {
  return requestClient.post('/mes/qc/seed-quality/retry-bpm', undefined, { params: { id } });
}
export namespace MesQcSeedQualityApi {
@@ -110,6 +212,8 @@
  export interface SubmitParams {
    code: string;
    /** å…¼å®¹å­—段:一次提交会同时生成加工部、销售部两条记录 */
    qcLine?: 1 | 2;
    batchCode?: string;
    indicators: Indicator[];
    reason?: string;
@@ -125,15 +229,47 @@
  return requestClient.post<number>('/mes/qc/seed-quality/submit', data);
}
/** åŒçº¿è´¨æ£€ï¼šåŒä¸€è¢‹ç åœ¨åŠ å·¥éƒ¨ã€é”€å”®éƒ¨å„æœ‰ä¸€æ¡è´¨æ£€è®°å½• */
export interface SeedQualityLineStatus {
  id: number;
  qcLine: number;
  workflowStatus: number;
}
export interface SeedQualityScan {
  code: string; businessCode?: string; batchCode?: string;
  itemId?: number; itemCode?: string; itemName?: string;
  indicators?: EffectiveIndicator[];
  inbound: boolean; canceled: boolean; qualityId?: number; result?: number;
  qualityIds?: number[]; qualityLines?: SeedQualityLineStatus[];
  scanType?: 'BAG' | 'PALLET' | 'BATCH'; totalCount?: number;
  pendingBagIds?: number[]; completedBagIds?: number[];
}
export function resolveSeedQuality(code: string, scanType: 'BAG' | 'PALLET' | 'BATCH' = 'BAG') {
  return requestClient.get<SeedQualityScan>('/mes/qc/seed-quality/resolve', { params: { code, scanType }, suppressErrorMessage: true });
export function resolveSeedQuality(code: string, scanType: 'BAG' | 'PALLET' | 'BATCH' = 'BAG', qcLine: 1 | 2 = 1) {
  return requestClient.get<SeedQualityScan>('/mes/qc/seed-quality/resolve', { params: { code, scanType, qcLine }, suppressErrorMessage: true });
}
export function submitSeedQualityGroup(data: MesQcSeedQualityApi.SubmitParams & { scanType: 'BAG' | 'PALLET' | 'BATCH'; bagIds: number[] }) {
  return requestClient.post<{ processedCount: number }>('/mes/qc/seed-quality/submit-group', data, { suppressErrorMessage: true });
}
/** ç»é”€å•†é€€è´§è´¨æ£€ï¼šè§£æžé€€è´§å•待质检信息 */
export interface SeedReturnQualityInfo {
  returnSalesId: number;
  returnSalesCode: string;
  returnSalesStatus: number;
  itemId?: number;
  batchId?: number;
  batchCode?: string;
  indicators?: EffectiveIndicator[];
  qualityId?: number;
  result?: number;
  workflowStatus?: number;
}
export function resolveSeedReturnQuality(returnSalesId: number) {
  return requestClient.get<SeedReturnQualityInfo>('/mes/qc/seed-quality/return/resolve', { params: { returnSalesId }, suppressErrorMessage: true });
}
export function submitSeedReturnQuality(data: { returnSalesId: number; indicators: MesQcSeedQualityApi.Indicator[]; reason?: string }) {
  return requestClient.post<number>('/mes/qc/seed-quality/return/submit', data, { suppressErrorMessage: true });
}
export function approveSeedReturnQuality(data: { id: number; approved: boolean; opinion: string }) {
  return requestClient.post('/mes/qc/seed-quality/return/approve', data, { suppressErrorMessage: true });
}
src/api/mes/trace/batch.ts
@@ -66,6 +66,7 @@
  export interface SalesTarget {
    salesCode?: string;
    shipmentId?: number;
    salesDate?: string;
    salesOrderCode?: string;
    clientName?: string;
@@ -147,4 +148,4 @@
  return requestClient.get<MesTraceBatchApi.Detail>('/mes/trace/batch/detail', {
    params: { code },
  });
}
}
src/api/mes/wm/materialstock/index.ts
@@ -11,6 +11,7 @@
    itemCode?: string; // ç‰©æ–™ç¼–码
    itemName?: string; // ç‰©æ–™åç§°
    specification?: string; // è§„格型号
    itemTypeName?: string; // ç‰©æ–™ç±»åˆ«åç§°
    unitMeasureName?: string; // è®¡é‡å•位名称
    batchId?: number; // æ‰¹æ¬¡ç¼–号
    batchCode?: string; // æ‰¹æ¬¡å·
@@ -74,6 +75,10 @@
  );
}
export function getPickingStocks(lineId: number) {
  return requestClient.get<MesWmMaterialStockApi.MaterialStock[]>('/mes/wm/material-stock/picking-list', { params: { lineId } });
}
/** æŸ¥è¯¢åº“存台账详情 */
export interface MaterialStockBagDetail {
  id: number;
@@ -91,10 +96,13 @@
  frozen?: boolean;
  inboundTime?: string;
  outboundTime?: string;
  outboundBizType?: number;
  pickingStatus?: 'PICKED' | 'RESERVED';
}
export function getMaterialStockBagDetails(params: {
  bagCode?: string;
  includeHistory?: boolean;
  materialStockId: number;
  pageNo?: number;
  pageSize?: number;
src/api/mes/wm/productsales/detail/index.ts
@@ -6,6 +6,7 @@
    id?: number; // æ˜Žç»†ç¼–号
    lineId?: number; // å‡ºåº“单行编号
    salesId?: number; // å‡ºåº“单编号
    shipmentId?: number; // å‘货记录编号
    itemId?: number; // ç‰©æ–™ç¼–号
    itemCode?: string; // ç‰©æ–™ç¼–码
    itemName?: string; // ç‰©æ–™åç§°
@@ -50,6 +51,10 @@
  );
}
export function createMobileManualDetail(data: MesWmProductSalesDetailApi.ProductSalesDetail) {
  return requestClient.post<number>('/mes/wm/product-sales-detail/create-mobile-manual', data);
}
/** ä¿®æ”¹é”€å”®å‡ºåº“明细 */
export function updateProductSalesDetail(
  data: MesWmProductSalesDetailApi.ProductSalesDetail,
src/api/mes/wm/productsales/index.ts
@@ -23,6 +23,8 @@
    shippingNumber?: string; // è¿è¾“单号
    status?: number; // å•据状态
    remark?: string; // å¤‡æ³¨
    closeReason?: string;
    partiallyShipped?: boolean;
    stockState?: number; // åº“存状态
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
@@ -33,6 +35,19 @@
    quantity?: number;
    scanType?: 'BAG' | 'PALLET' | 'BATCH';
    bagIds?: number[];
    lineId?: number;
    materialStockId?: number;
    manual?: boolean;
  }
  export interface Shipment {
    id: number;
    salesId: number;
    status: number;
    carrier?: string;
    shippingNumber?: string;
    outboundTime?: string;
    createTime?: string;
  }
  export interface PickingBag {
@@ -40,6 +55,7 @@
    code: string;
    businessCode?: string;
    palletId?: number;
    materialStockId?: number;
    selectable: boolean;
    unavailableReason?: string;
  }
@@ -84,6 +100,10 @@
  return requestClient.get<MesWmProductSalesApi.ProductSales>(
    `/mes/wm/product-sales/get?id=${id}`,
  );
}
export function getProductSalesShipments(id: number) {
  return requestClient.get<MesWmProductSalesApi.Shipment[]>('/mes/wm/product-sales/shipments', { params: { id } });
}
/** æ–°å¢žé”€å”®å‡ºåº“单 */
@@ -140,6 +160,14 @@
  return requestClient.put(`/mes/wm/product-sales/cancel?id=${id}`);
}
export function closeProductSalesRemaining(id: number, reason: string) {
  return requestClient.put('/mes/wm/product-sales/close-remaining', undefined, { params: { id, reason } });
}
export function cancelProductSalesShipment(id: number) {
  return requestClient.put(`/mes/wm/product-sales/cancel-shipment?id=${id}`);
}
/** æ ¡éªŒé”€å”®å‡ºåº“单拣货数量是否与出库数量一致 */
export function checkProductSalesQuantity(id: number) {
  return requestClient.get<boolean>(
src/api/mes/wm/productsales/line/index.ts
@@ -15,6 +15,8 @@
    specification?: string; // è§„格型号
    unitMeasureName?: string; // å•位
    quantity?: number; // å‡ºåº“数量
    shippedQuantity?: number;
    remainingQuantity?: number;
    pickedQuantity?: number; // å·²æ‹£è´§æ•°é‡
    batchId?: number; // æ‰¹æ¬¡ç¼–号
    batchCode?: string; // æ‰¹æ¬¡å·
src/api/mes/wm/transaction/index.ts
@@ -14,9 +14,14 @@
    itemId?: number; // ç‰©æ–™ID
    itemCode?: string; // ç‰©æ–™ç¼–码
    itemName?: string; // ç‰©æ–™åç§°
    specification?: string; // è§„格型号
    itemTypeName?: string; // ç‰©æ–™ç±»åˆ«åç§°
    quantity?: number; // å˜åŠ¨æ•°é‡
    batchId?: number; // æ‰¹æ¬¡ID
    batchCode?: string; // æ‰¹æ¬¡å·
    palletCode?: string; // å‡ºåº“明细中的托盘码
    bagCode?: string; // è¢‹ç 
    shipmentId?: number;
    warehouseId?: number; // ä»“库ID
    warehouseName?: string; // ä»“库名称
    locationId?: number; // åº“区ID
@@ -30,6 +35,9 @@
  /** åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    itemId?: number;
    batchCodeExact?: string;
    bizCodeExact?: string;
    shipmentId?: number;
    batchCode?: string;
    warehouseId?: number;
    locationId?: number;
src/api/mes/wm/warehouse/area/index.ts
ÎļþÒÑɾ³ý
src/api/mes/wm/warehouse/location/index.ts
ÎļþÒÑɾ³ý
src/api/system/appearance/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
import { requestClient } from '#/api/request';
export namespace SystemAppearanceApi {
  /** ç³»ç»Ÿå¤–观信息 */
  export interface Appearance {
    logoBlobId?: number;
    logoUrl?: string;
    loginBackgroundBlobId?: number;
    loginBackgroundUrl?: string;
  }
  /** ä¿å­˜å¤–观设置请求,传 null è¡¨ç¤ºæ¸…除 */
  export interface SaveReq {
    logoBlobId?: null | number;
    loginBackgroundBlobId?: null | number;
  }
  /** å…¬å…±ä¸Šä¼ è¿”回的文件信息 */
  export interface PublicBlob {
    id: number;
    previewURL?: string;
    downloadURL?: string;
    originalFilename?: string;
  }
}
/** èŽ·å¾—ç³»ç»Ÿå¤–è§‚ */
export function getSystemAppearance() {
  return requestClient.get<SystemAppearanceApi.Appearance>(
    '/system/appearance/get',
  );
}
/** ä¿å­˜ç³»ç»Ÿå¤–è§‚ */
export function saveSystemAppearance(data: SystemAppearanceApi.SaveReq) {
  return requestClient.post<boolean>('/system/appearance/save', data);
}
/** ä¸Šä¼ å¤–观图片(永久有效,未登录也可访问) */
export function uploadAppearanceImage(file: File) {
  const formData = new FormData();
  formData.append('files', file);
  return requestClient.post<SystemAppearanceApi.PublicBlob[]>(
    '/system/storage-blob/public-upload',
    formData,
    { headers: { 'Content-Type': 'multipart/form-data' } },
  );
}
src/api/wls/warehouse/area/index.ts
ÎļþÒÑɾ³ý
src/api/wls/warehouse/location/index.ts
ÎļþÒÑɾ³ý
src/layouts/auth.vue
@@ -6,6 +6,7 @@
import { $t } from '#/locales';
import { terminalType } from '#/mobile/terminal';
import { appearanceLogoUrl } from '#/utils/system-appearance';
declare const __APP_LOGO__: string;
@@ -22,6 +23,7 @@
  <AuthPageLayout
    v-else
    :app-name="appName"
    :brand-logo="appearanceLogoUrl"
    :logo="logo"
    :logo-dark="logoDark"
    page-description="数字赋能农业 Â· ç§‘技引领未来"
src/main.ts
@@ -2,6 +2,7 @@
import { unmountGlobalLoading } from './packages/utils/src';
import { overridesPreferences, preferencesExtension } from './preferences';
import { loadSystemAppearance } from './utils/system-appearance';
declare const __APP_LOGO__: string;
declare const __APP_FAVICON__: string;
@@ -52,6 +53,9 @@
    }
  }
  // åŠ è½½æœåŠ¡ç«¯é…ç½®çš„å¤–è§‚ï¼ˆLogo、登录背景图),失败时保留默认样式
  await loadSystemAppearance();
  // å¯åŠ¨åº”ç”¨å¹¶æŒ‚è½½
  // vue应用主要逻辑及视图
  const { bootstrap } = await import('./bootstrap');
src/mobile/duplicate-bind.wav
Binary files differ
src/mobile/duplicate-outbound.wav
Binary files differ
src/mobile/duplicate-quality.wav
Binary files differ
src/mobile/duplicate-scan.wav
Binary files differ
src/mobile/scanVoice.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
import duplicateScanAudio from './duplicate-scan.wav';
import duplicateBindAudio from './duplicate-bind.wav';
import duplicateOutboundAudio from './duplicate-outbound.wav';
import duplicateQualityAudio from './duplicate-quality.wav';
let audio: HTMLAudioElement | undefined;
export type ScanVoiceOperation = 'inbound' | 'outbound' | 'quality' | 'bind';
const sources: Record<ScanVoiceOperation, string> = {
  inbound: duplicateScanAudio,
  outbound: duplicateOutboundAudio,
  quality: duplicateQualityAudio,
  bind: duplicateBindAudio,
};
export async function speakDuplicateScan(operation: ScanVoiceOperation = 'inbound'): Promise<boolean> {
  try {
    if (!audio) {
      audio = new Audio();
      audio.preload = 'auto';
    }
    audio.pause();
    if (audio.getAttribute('src') !== sources[operation]) audio.src = sources[operation];
    audio.currentTime = 0;
    await audio.play();
    return true;
  } catch {
    return false;
  }
}
src/packages/effects/layouts/src/authentication/authentication.vue
@@ -11,6 +11,7 @@
interface Props {
  appName?: string;
  brandLogo?: string;
  logo?: string;
  logoDark?: string;
  pageTitle?: string;
@@ -24,6 +25,7 @@
const props = withDefaults(defineProps<Props>(), {
  appName: '',
  brandLogo: '',
  copyright: true,
  logo: '',
  logoDark: '',
@@ -112,7 +114,7 @@
          <div class="agri-brand">
            <div class="flex items-center gap-4">
              <img
                src="/images/tahe-logo.png"
                :src="brandLogo || '/images/tahe-logo.png'"
                alt="塔河种业"
                class="h-16 w-auto shrink-0 rounded-xl bg-white/95 p-1.5 shadow-lg"
              />
@@ -222,7 +224,7 @@
  min-height: 100dvh;
  overflow-y: auto;
  background-color: #124b39;
  background-image: url('/agriculture-login-bg.png');
  background-image: var(--login-bg-image, url('/agriculture-login-bg.png'));
  background-position: center;
  background-size: cover;
  isolation: isolate;
@@ -367,7 +369,7 @@
.dark .login-background {
  background-image:
    linear-gradient(90deg, rgba(2, 26, 20, 0.9), rgba(2, 26, 20, 0.62)),
    url('/agriculture-login-bg.png');
    var(--login-bg-image, url('/agriculture-login-bg.png'));
}
.dark .agri-form-panel {
src/utils/system-appearance.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
import { preferencesManager } from '@vben/preferences';
import { ref } from 'vue';
/** ç™»å½•页品牌 Logo,未配置时为空 */
export const appearanceLogoUrl = ref('');
/** ç™»å½•页背景图,未配置时为空 */
export const appearanceLoginBackgroundUrl = ref('');
/** é¦–次应用时记录的默认 Logo,用于清除配置后回退 */
let defaultLogoSource: string | undefined;
interface AppearancePayload {
  logoUrl?: string;
  loginBackgroundUrl?: string;
}
/**
 * åº”用外观设置:更新首页侧边栏 Logo ä¸Žç™»å½•页样式
 */
export function applyAppearance(appearance: AppearancePayload) {
  appearanceLogoUrl.value = appearance.logoUrl ?? '';
  appearanceLoginBackgroundUrl.value = appearance.loginBackgroundUrl ?? '';
  defaultLogoSource ??= preferencesManager.getPreferences().logo.source;
  preferencesManager.updatePreferences({
    logo: { source: appearanceLogoUrl.value || defaultLogoSource },
  });
  if (appearanceLoginBackgroundUrl.value) {
    document.documentElement.style.setProperty(
      '--login-bg-image',
      `url("${appearanceLoginBackgroundUrl.value}")`,
    );
  } else {
    document.documentElement.style.removeProperty('--login-bg-image');
  }
}
/**
 * å¯åŠ¨æ—¶åŠ è½½ç³»ç»Ÿå¤–è§‚ï¼Œå¤±è´¥æ—¶é™é»˜ä¿ç•™é»˜è®¤æ ·å¼
 */
export async function loadSystemAppearance() {
  try {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), 2000);
    const baseURL = ((import.meta.env.VITE_GLOB_API_URL as string) || '').replace(
      /\/+$/,
      '',
    );
    const response = await fetch(`${baseURL}/system/appearance/get`, {
      headers: { 'tenant-id': '1' },
      signal: controller.signal,
    });
    clearTimeout(timer);
    if (!response.ok) {
      return;
    }
    const result = await response.json();
    if (result?.code !== 0 || !result.data) {
      return;
    }
    applyAppearance(result.data);
  } catch {
    // å¤–观加载失败不影响应用启动
  }
}
src/views/_core/authentication/login.vue
@@ -13,6 +13,7 @@
import { useAuthStore } from '#/store';
import { selectTerminal, terminalType } from '#/mobile/terminal';
import { errorText } from '#/mobile/api';
import { appearanceLogoUrl } from '#/utils/system-appearance';
import { useRoute } from 'vue-router';
defineOptions({ name: 'Login' });
@@ -26,6 +27,11 @@
const verifyRef = ref();
const captchaType = 'blockPuzzle'; // éªŒè¯ç ç±»åž‹ï¼š'blockPuzzle' | 'clickWord'
/** å“ç‰Œ Logo:优先使用服务端配置,否则使用默认图片 */
const brandLogoSrc = computed(
  () => appearanceLogoUrl.value || '/images/tahe-logo.png',
);
/** æ»‘块验证码尺寸:移动端按视口宽度收缩,避免超出屏幕无法拖动 */
const captchaImgSize = computed(() => {
@@ -98,7 +104,7 @@
  <div>
    <div class="login-brand">
      <img
        src="/images/tahe-logo.png"
        :src="brandLogoSrc"
        alt="塔河种业"
        class="login-brand-logo"
      />
src/views/bpm/processInstance/detail/index.vue
@@ -313,7 +313,10 @@
                      processDefinition?.formType === BpmModelFormType.CUSTOM
                    "
                  >
                    <BusinessFormComponent :id="processInstance?.businessKey" />
                    <BusinessFormComponent
                      :id="processInstance?.businessKey"
                      :process-definition-key="processDefinition?.key"
                    />
                  </div>
                </Col>
                <Col :xs="24" :sm="24" :md="6" :lg="6" :xl="8">
src/views/crm/customer/data.ts
@@ -10,6 +10,7 @@
import { getSimpleUserList } from '#/api/system/user';
import { AreaCascader } from '#/components/area';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import { getRangePickerDefaultProps } from '#/utils';
/** æ–°å¢ž/修改的表单 */
@@ -31,6 +32,69 @@
      rules: 'required',
      componentProps: {
        placeholder: '请输入客户名称',
        allowClear: true,
      },
    },
    {
      fieldName: 'customerName',
      label: '客户姓名',
      component: 'Input',
      componentProps: {
        placeholder: '请输入客户姓名',
        allowClear: true,
      },
    },
    {
      fieldName: 'socialCreditCode',
      label: '社会信用代码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入社会信用代码',
        allowClear: true,
      },
    },
    {
      fieldName: 'idCardNo',
      label: '身份证号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入身份证号',
        allowClear: true,
      },
    },
    {
      fieldName: 'filingSerialNo',
      label: '备案流水号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入备案流水号',
        allowClear: true,
      },
    },
    {
      fieldName: 'bankAccountName',
      label: '银行账户名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入银行账户名称',
        allowClear: true,
      },
    },
    {
      fieldName: 'region',
      label: '所属区域',
      component: 'Input',
      componentProps: {
        placeholder: '请输入所属区域',
        allowClear: true,
      },
    },
    {
      fieldName: 'parentDealerId',
      label: '所属上级经销商',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        placeholder: '请选择所属上级经销商',
        allowClear: true,
      },
    },
@@ -184,6 +248,60 @@
      },
    },
    {
      fieldName: 'customerName',
      label: '客户姓名',
      component: 'Input',
      componentProps: {
        placeholder: '请输入客户姓名',
        allowClear: true,
      },
    },
    {
      fieldName: 'socialCreditCode',
      label: '社会信用代码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入社会信用代码',
        allowClear: true,
      },
    },
    {
      fieldName: 'idCardNo',
      label: '身份证号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入身份证号',
        allowClear: true,
      },
    },
    {
      fieldName: 'filingSerialNo',
      label: '备案流水号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入备案流水号',
        allowClear: true,
      },
    },
    {
      fieldName: 'region',
      label: '所属区域',
      component: 'Input',
      componentProps: {
        placeholder: '请输入所属区域',
        allowClear: true,
      },
    },
    {
      fieldName: 'parentDealerId',
      label: '所属上级经销商',
      component: markRaw(CrmCustomerSelect),
      componentProps: {
        placeholder: '请选择所属上级经销商',
        allowClear: true,
      },
    },
    {
      fieldName: 'mobile',
      label: '手机号',
      component: 'Input',
@@ -267,6 +385,48 @@
      slots: { default: 'name' },
    },
    {
      field: 'customerName',
      title: '客户姓名',
      minWidth: 120,
      formatter: ({ cellValue }) => cellValue || '-',
    },
    {
      field: 'socialCreditCode',
      title: '社会信用代码',
      minWidth: 180,
      formatter: ({ cellValue }) => cellValue || '-',
    },
    {
      field: 'idCardNo',
      title: '身份证号',
      minWidth: 180,
      formatter: ({ cellValue }) => cellValue || '-',
    },
    {
      field: 'filingSerialNo',
      title: '备案流水号',
      minWidth: 160,
      formatter: ({ cellValue }) => cellValue || '-',
    },
    {
      field: 'bankAccountName',
      title: '银行账户名称',
      minWidth: 180,
      formatter: ({ cellValue }) => cellValue || '-',
    },
    {
      field: 'region',
      title: '所属区域',
      minWidth: 120,
      formatter: ({ cellValue }) => cellValue || '-',
    },
    {
      field: 'parentDealerName',
      title: '所属上级经销商',
      minWidth: 160,
      formatter: ({ cellValue }) => cellValue || '-',
    },
    {
      field: 'source',
      title: '客户来源',
      minWidth: 100,
src/views/crm/customer/detail/data.ts
@@ -71,6 +71,34 @@
      label: '客户名称',
    },
    {
      field: 'customerName',
      label: '客户姓名',
    },
    {
      field: 'socialCreditCode',
      label: '社会信用代码',
    },
    {
      field: 'idCardNo',
      label: '身份证号',
    },
    {
      field: 'filingSerialNo',
      label: '备案流水号',
    },
    {
      field: 'bankAccountName',
      label: '银行账户名称',
    },
    {
      field: 'region',
      label: '所属区域',
    },
    {
      field: 'parentDealerName',
      label: '所属上级经销商',
    },
    {
      field: 'source',
      label: '客户来源',
      render: (val) =>
src/views/mes/md/workstation/data.ts
@@ -16,8 +16,6 @@
import { z } from '#/adapter/form';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import { getWarehouseAreaSimpleList } from '#/api/mes/wm/warehouse/area';
import { getWarehouseLocationSimpleList } from '#/api/mes/wm/warehouse/location';
import { ProProcessSelect } from '#/views/mes/process-design/process/components';
import { MdWorkshopSelect } from './components';
@@ -108,55 +106,8 @@
        allowClear: true,
        api: getWarehouseSimpleList,
        labelField: 'name',
        onChange: async () => {
          await formApi?.setFieldValue('locationId', undefined);
          await formApi?.setFieldValue('areaId', undefined);
        },
        placeholder: '请选择仓库',
        valueField: 'id',
      },
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: 'Select',
      dependencies: {
        triggerFields: ['warehouseId'],
        disabled: (values) => !values.warehouseId,
        async componentProps(values) {
          const list = values.warehouseId
            ? await getWarehouseLocationSimpleList(values.warehouseId)
            : [];
          return {
            allowClear: true,
            onChange: async () => {
              await formApi?.setFieldValue('areaId', undefined);
            },
            fieldNames: { label: 'name', value: 'id' },
            options: list,
            placeholder: '请选择库区',
          };
        },
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: 'Select',
      dependencies: {
        triggerFields: ['locationId'],
        disabled: (values) => !values.locationId,
        async componentProps(values) {
          const list = values.locationId
            ? await getWarehouseAreaSimpleList(values.locationId)
            : [];
          return {
            allowClear: true,
            fieldNames: { label: 'name', value: 'id' },
            options: list,
            placeholder: '请选择库位',
          };
        },
      },
    },
    {
src/views/mes/pro/bagCode/data.ts
@@ -24,10 +24,6 @@
      },
    },
    {
      fieldName: 'businessCode', label: '业务码', component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入出入库业务码' },
    },
    {
      fieldName: 'batchCode',
      label: '批次号',
      component: 'Input',
@@ -85,7 +81,6 @@
      fixed: 'left',
      minWidth: 220,
    },
    { field: 'businessCode', title: '业务码(出入库)', minWidth: 210 },
    {
      field: 'bagNo',
      title: '袋序号',
src/views/mes/pro/bagCode/index.vue
@@ -14,8 +14,6 @@
    deleteBagCodesByUuid,
    exportBagCodeExcel,
    exportBagQr,
    exportBagBusinessQr,
    generateBagBusinessCodes,
    getBagCodePage,
    bindBagCodeBatch,
    unbindBagCodePallet,
@@ -31,7 +29,6 @@
  import RawGenerateModal from "./modules/raw-generate-form.vue";
  import ReplaceModal from "./modules/replace-form.vue";
  import QrTraceModal from "../components/qr-trace-modal.vue";
  import BusinessQrModal from "../components/business-qr.vue";
  import QrExportTaskModal from '../components/qr-export-task-modal.vue';
  defineOptions({ name: "MesProBagCode" });
@@ -68,29 +65,6 @@
    destroyOnClose: true,
  });
  const [BusinessQrViewModal, businessQrApi] = useVbenModal({
    connectedComponent: BusinessQrModal, destroyOnClose: true,
  });
  function handleBusinessQr(row: MesProBagCodeApi.BagCode) {
    if (!row.businessCode) { message.warning('请先补生成业务码'); return; }
    businessQrApi.setData({ code: row.businessCode }).open();
  }
  async function handleGenerateBusinessCodes() {
    const records = getSelectedRecords();
    if (!records) return;
    if (records.length > 200) { message.warning('单次最多补生成 200 æ¡'); return; }
    if (records.some(row => row.status === MesProBagCodeStatusEnum.CANCELED)) {
      message.warning('已作废袋码不可补生成业务码'); return;
    }
    const ids = records.map(row => row.id).filter((id): id is number => id != null);
    if (ids.length !== records.length) { message.warning('所选袋码缺少编号'); return; }
    const count = await generateBagBusinessCodes(ids);
    message.success(`已补生成 ${count} ä»½ä¸šåŠ¡ç ï¼Œå·²æœ‰ç å€¼ä¿æŒä¸å˜`);
    handleRefresh();
  }
  /** åˆ·æ–°è¡¨æ ¼ */
  function handleRefresh() {
    gridApi.query();
@@ -123,7 +97,7 @@
  }
  /** å¯¼å‡ºäºŒç»´ç  ZIP(按当前查询条件的生成组/批次) */
  async function handleExportQr(kind: 'trace' | 'business' = 'trace') {
  async function handleExportQr() {
    const values = await gridApi.formApi.getValues();
    const params: { uuid?: string; batchId?: number } = {};
    if (values.uuid) {
@@ -136,7 +110,7 @@
      message.warning("请先按生成组或批次筛选后再导出二维码");
      return;
    }
    const bizType = kind === 'business' ? 'BAG_BUSINESS' : 'BAG_LABEL';
    const bizType = 'BAG_LABEL';
    const count = await countQrExport({ bizType, ...params });
    if (count.mode === 'ASYNC') {
      const task = await submitQrExport({ bizType, ...params });
@@ -144,8 +118,8 @@
      message.success(`已提交 ${count.totalCount} å¼ äºŒç»´ç çš„后台导出任务`);
      return;
    }
    const data = await (kind === 'business' ? exportBagBusinessQr : exportBagQr)(params);
    downloadFileFromBlobPart({ fileName: kind === 'business' ? '袋码业务二维码.zip' : '袋码追溯二维码.zip', source: data });
    const data = await exportBagQr(params);
    downloadFileFromBlobPart({ fileName: '袋码追溯二维码.zip', source: data });
  }
  /** å¯¼å‡ºè¢‹ç  Excel(交付印刷),导出后袋码状态变为已印刷,需刷新列表 */
@@ -370,7 +344,6 @@
    <BindPalletFormModal @success="handleRefresh" />
    <ReplaceFormModal @success="handleReplaceSuccess" />
    <QrTraceQueryModal />
    <BusinessQrViewModal />
    <QrExportTaskModal ref="qrExportTaskModal" />
    <Grid table-title="袋码列表(一袋一码)">
      <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">袋码列表(一袋一码)</span></template>
@@ -380,14 +353,12 @@
          { label: '录入批次', auth: ['mes:pro-bag-code:update'], onClick: handleBindBatch },
          { label: '绑定托盘', auth: ['mes:pro-bag-code:update'], onClick: handleBindPallet },
          { label: '扫码追溯', icon: ACTION_ICON.SEARCH, auth: ['mes:pro-bag-code:query'], onClick: handleTrace }]" />
          <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '追溯二维码', auth: ['mes:pro-bag-code:export'], onClick: () => handleExportQr('trace') },
          { label: '业务二维码', auth: ['mes:pro-bag-code:export'], onClick: () => handleExportQr('business') },
          <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '追溯二维码', auth: ['mes:pro-bag-code:export'], onClick: () => handleExportQr() },
          { label: 'Excel æ•°æ®åŒ…', auth: ['mes:pro-bag-code:export'], onClick: handleExport }]">
            <template #more><Button>导出 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
          </TableAction>
          <Button @click="qrExportTaskModal?.showLast()">后台导出任务</Button>
          <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '生成原始袋码', auth: ['mes:pro-bag-code:create'], onClick: handleRawGenerate },
          { label: '补生成业务码', auth: ['mes:pro-bag-code:create'], onClick: handleGenerateBusinessCodes },
          { label: '破损替换', auth: ['mes:pro-bag-code:replace'], onClick: handleReplace },
          { label: '解绑托盘', auth: ['mes:pro-bag-code:update'], onClick: handleUnbindPallet }]">
            <template #more><Button>更多操作 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
@@ -401,10 +372,6 @@
              type: 'link',
              auth: ['mes:pro-bag-code:query'],
              onClick: handleViewQr.bind(null, row),
            },
            {
              label: '业务二维码', type: 'link', auth: ['mes:pro-bag-code:query'],
              ifShow: !!row.businessCode, onClick: handleBusinessQr.bind(null, row),
            },
            {
              label: $t('common.delete'),
src/views/mes/pro/batch/data.ts
@@ -80,6 +80,8 @@
  existingBinding?: BindingMode,
): VbenFormSchema[] {
  const disabled = formType === 'detail';
  // æ‰¹æ¬¡å·ä»…在新增、补录时允许人工输入;留空由后端按「年份+3位流水」自动生成
  const codeEditable = formType === 'create' || formType === 'fill';
  return [
    {
      fieldName: 'id',
@@ -94,12 +96,9 @@
      label: '批次号',
      component: 'Input',
      componentProps: {
        disabled: true,
        placeholder: '补录后自动生成',
      },
      dependencies: {
        triggerFields: [''],
        show: () => formType !== 'create',
        disabled: !codeEditable,
        maxlength: 7,
        placeholder: codeEditable ? '留空自动生成,如 2026001' : '批次号自动生成',
      },
    },
    {
@@ -276,7 +275,6 @@
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    { fieldName: 'businessCode', label: '业务码', component: 'Input', componentProps: { allowClear: true, placeholder: '请输入作业扫码业务码' } },
    {
      fieldName: 'code',
      label: '批次号',
@@ -346,7 +344,6 @@
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<MesProBatchApi.Batch>['columns'] {
  return [
    { field: 'businessCode', title: '业务码(作业扫码)', minWidth: 210 },
    {
      field: 'code',
      title: '批次号',
@@ -354,6 +351,13 @@
      minWidth: 200,
      slots: { default: 'code' },
    },
    {
      field: 'palletCodes',
      title: '托盘码',
      minWidth: 200,
      showOverflow: 'tooltip',
      slots: { default: 'palletCodes' },
    },
    { field: 'itemName', title: '品种名称', minWidth: 150 },
    { field: 'itemCode', title: '品种编码', minWidth: 150 },
    {
src/views/mes/pro/batch/index.vue
@@ -14,8 +14,6 @@
    deleteBatch,
    exportBatchExcel,
    exportBatchQr,
    exportBatchBusinessQr,
    generateBatchBusinessCodes,
    getBatchPage,
    updateBatchStatus,
  } from "#/api/mes/pro/batch";
@@ -27,7 +25,6 @@
  import Form from "./modules/form.vue";
  import PreGenerateModal from "./modules/pre-generate.vue";
  import QrTraceModal from "../components/qr-trace-modal.vue";
  import BusinessQrModal from "../components/business-qr.vue";
  import QrExportTaskModal from '../components/qr-export-task-modal.vue';
  const qrExportTaskModal = ref<InstanceType<typeof QrExportTaskModal>>();
@@ -48,28 +45,6 @@
  });
  /** åˆ·æ–°è¡¨æ ¼ */
  const [BusinessQrViewModal, businessQrApi] = useVbenModal({ connectedComponent: BusinessQrModal, destroyOnClose: true });
  function handleBusinessQr(row: MesProBatchApi.Batch) {
    businessQrApi.setData({ code: row.businessCode }).open();
  }
  async function handleGenerateBusiness(row: MesProBatchApi.Batch) {
    if (row.id == null) return;
    const count = await generateBatchBusinessCodes([row.id]);
    message.success(`已补生成 ${count} ä»½ä¸šåŠ¡ç ï¼Œå·²æœ‰ç å€¼ä¿æŒä¸å˜`); handleRefresh();
  }
  async function handleExportBusiness(row: MesProBatchApi.Batch) {
    if (row.id == null) return;
    const params = { bizType: 'BATCH_BUSINESS' as const, batchId: row.id };
    const count = await countQrExport(params);
    if (count.mode === 'ASYNC') {
      qrExportTaskModal.value?.show(await submitQrExport(params));
      message.success(`已提交 ${count.totalCount} å¼ äºŒç»´ç çš„后台导出任务`);
      return;
    }
    const data = await exportBatchBusinessQr(row.id);
    downloadFileFromBlobPart({ fileName: '批次与托盘业务二维码.zip', source: data });
  }
  function handleRefresh() {
    gridApi.query();
  }
@@ -200,7 +175,6 @@
    <FormModal @success="handleRefresh" />
    <PreGenerateModalApi @success="handlePreGenerateSuccess" />
    <QrTraceQueryModal />
    <BusinessQrViewModal />
    <QrExportTaskModal ref="qrExportTaskModal" />
    <Grid table-title="生产批次列表">
      <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">生产批次列表</span></template>
@@ -220,6 +194,10 @@
          {{ getDisplayCode(row) }}
        </Button>
        <Tag v-if="!row.code" color="warning">待补录</Tag>
      </template>
      <template #palletCodes="{ row }">
        <span v-if="row.palletCodes?.length">{{ row.palletCodes.join(', ') }}</span>
        <span v-else>-</span>
      </template>
      <template #actions="{ row }">
        <TableAction :actions="[
@@ -264,9 +242,6 @@
            },
          ]"
          :drop-down-actions="[
            { label: '业务二维码', auth: ['mes:pro-batch:query'], ifShow: !!row.businessCode, onClick: () => handleBusinessQr(row) },
            { label: '补生成业务码', auth: ['mes:pro-batch:create'], ifShow: !row.businessCode && row.status !== MesProBatchStatusEnum.CANCELED, onClick: () => handleGenerateBusiness(row) },
            { label: '导出整套业务二维码', auth: ['mes:pro-batch:export'], ifShow: row.status !== MesProBatchStatusEnum.CANCELED, onClick: () => handleExportBusiness(row) },
            {
              label: '开始生产',
              auth: ['mes:pro-batch:update'],
src/views/mes/pro/batch/modules/form.vue
@@ -495,6 +495,8 @@
        // æ¸…空上次打开时勾选的托盘/吨袋,避免残留值误触发「批次进入生产中」
        await formApi.setFieldValue("palletIds", undefined);
        await formApi.setFieldValue("tonBagIds", undefined);
        // æ¸…空上次打开时人工输入的批次号,编辑/补录场景随后由接口数据回填
        await formApi.setFieldValue("code", undefined);
        void loadPalletPage(true);
        void loadTonBagPage(true);
      }
src/views/mes/pro/components/qr-trace-modal.vue
@@ -242,7 +242,6 @@
        label: "追溯码",
        span: 2,
      },
      { field: 'businessCode', label: '业务码(出入库)', span: 2 },
      {
        field: "bagNo",
        label: "袋序号",
src/views/mes/pro/pallet/data.ts
@@ -10,7 +10,6 @@
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    { fieldName: 'businessCode', label: '业务码', component: 'Input', componentProps: { allowClear: true, placeholder: '请输入出入库业务码' } },
    {
      fieldName: 'code',
      label: '托盘码',
@@ -55,7 +54,6 @@
export function useGridColumns(): VxeTableGridOptions<MesProPalletApi.Pallet>['columns'] {
  return [
    { type: 'checkbox', width: 50 },
    { field: 'businessCode', title: '业务码(出入库)', minWidth: 210 },
    {
      field: 'code',
      title: '托盘码',
src/views/mes/pro/pallet/index.vue
@@ -14,8 +14,6 @@
    exportPalletExcel,
    exportPalletQr,
    exportPalletQrByIds,
    exportPalletBusinessQr,
    generatePalletBusinessCodes,
    getPalletPage,
  } from "#/api/mes/pro/pallet";
  import { IconifyIcon } from "@vben/icons";
@@ -26,7 +24,6 @@
  import BindBatchModal from "./modules/bind-batch.vue";
  import GenerateModal from "./modules/generate-form.vue";
  import QrTraceModal from "../components/qr-trace-modal.vue";
  import BusinessQrModal from "../components/business-qr.vue";
  import QrExportTaskModal from '../components/qr-export-task-modal.vue';
  const qrExportTaskModal = ref<InstanceType<typeof QrExportTaskModal>>();
@@ -49,28 +46,6 @@
  });
  /** åˆ·æ–°è¡¨æ ¼ */
  const [BusinessQrViewModal, businessQrApi] = useVbenModal({ connectedComponent: BusinessQrModal, destroyOnClose: true });
  function handleBusinessQr(row: MesProPalletApi.Pallet) {
    businessQrApi.setData({ code: row.businessCode }).open();
  }
  function getBusinessIds() {
    const records = [...(gridApi.grid.getCheckboxReserveRecords?.() || []), ...(gridApi.grid.getCheckboxRecords?.() || [])] as MesProPalletApi.Pallet[];
    const ids = [...new Set(records.map(row => row.id).filter((id): id is number => id != null))];
    if (!ids.length || ids.length > 200) { message.warning('请勾选 1 è‡³ 200 æ¡æ‰˜ç›˜è®°å½•'); return; }
    if (records.some(row => row.status === 20)) { message.warning('已作废托盘不可操作业务码'); return; }
    return ids;
  }
  async function handleGenerateBusiness() {
    const ids = getBusinessIds(); if (!ids) return;
    const count = await generatePalletBusinessCodes(ids);
    message.success(`已补生成 ${count} ä»½ä¸šåŠ¡ç ï¼Œå·²æœ‰ç å€¼ä¿æŒä¸å˜`); handleRefresh();
  }
  async function handleExportBusiness() {
    const ids = getBusinessIds(); if (!ids) return;
    const data = await exportPalletBusinessQr(ids);
    downloadFileFromBlobPart({ fileName: '托盘业务二维码.zip', source: data });
  }
  function handleRefresh() {
    gridApi.query();
  }
@@ -215,7 +190,6 @@
    <GenerateFormModal @success="handlePreGenerateSuccess" />
    <BindBatchFormModal @success="handleRefresh" />
    <QrTraceQueryModal />
    <BusinessQrViewModal />
    <QrExportTaskModal ref="qrExportTaskModal" />
    <Grid table-title="托盘码列表">
      <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">托盘码列表</span></template>
@@ -225,14 +199,10 @@
          { label: '绑定批次', auth: ['mes:pro-pallet:update'], onClick: handleBindBatch },
          { label: '扫码追溯', icon: ACTION_ICON.SEARCH, auth: ['mes:pro-pallet:query'], onClick: handleTrace }]" />
          <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '追溯二维码(按批次)', auth: ['mes:pro-pallet:export'], onClick: handleExportQr },
          { label: '业务二维码(选中记录)', auth: ['mes:pro-pallet:export'], onClick: handleExportBusiness },
          { label: 'Excel æ•°æ®', auth: ['mes:pro-pallet:export'], onClick: handleExport }]">
            <template #more><Button>导出 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
          </TableAction>
          <Button @click="qrExportTaskModal?.showLast()">后台导出任务</Button>
          <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '补生成业务码(选中记录)', auth: ['mes:pro-pallet:create'], onClick: handleGenerateBusiness }]">
            <template #more><Button>更多操作 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
          </TableAction>
        </div>
      </template>
      <template #code="{ row }">
@@ -252,7 +222,6 @@
              auth: ['mes:pro-pallet:query'],
              onClick: handleViewQr.bind(null, row),
            },
            { label: '业务二维码', type: 'link', auth: ['mes:pro-pallet:query'], ifShow: !!row.businessCode, onClick: handleBusinessQr.bind(null, row) },
            {
              label: '导出二维码',
              type: 'link',
src/views/mes/pro/scan-in/index.vue
@@ -12,14 +12,6 @@
  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;
@@ -28,11 +20,7 @@
const code = 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[]>([]);
@@ -55,27 +43,12 @@
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(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;
@@ -96,26 +69,9 @@
async function loadOptions() {
  try {
    const [lineList, locationList] = await Promise.all([
      getLineSimpleList(),
      getWarehouseLocationSimpleList(),
    ]);
    lines.value = lineList ?? [];
    locations.value = locationList ?? [];
    lines.value = (await getLineSimpleList()) ?? [];
  } 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 = [];
  }
}
@@ -129,21 +85,20 @@
    message.warning('请先扫描追溯码');
    return;
  }
  if (!locationId.value || !areaId.value) {
    message.warning('请先选择 WIP æš‚存库区和库位');
    return;
  }
  loading.value = true;
  try {
    await inboundScanEvent({
    const result = await inboundScanEvent({
      eventId: `${scanType.value}-${Date.now()}`,
      bagCode,
      scanType: scanType.value,
      quantity: 1,
      lineCode: lineCode.value || undefined,
      locationId: locationId.value,
      areaId: areaId.value,
    });
    if (result.status === 'FAILED') {
      message.error(result.errorMessage || `${SCAN_TYPE_LABEL[scanType.value]} ${bagCode} å…¥åº“失败`);
      await refresh();
      return;
    }
    message.success(`${SCAN_TYPE_LABEL[scanType.value]} ${bagCode} å·²å®Œæˆå…¥åº“`);
    if (animationContext && pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      feedbackTimeline?.kill();
@@ -170,7 +125,7 @@
    if (errorMessage && /入库事务数量必须为正数|数量.*为 0|quantity.*positive/i.test(errorMessage)) {
      message.error('入库数量不能为 0,请检查该批次的袋数配置');
    } else if (!errorMessage || /UnexpectedRollbackException|系统异常|Internal Server Error/i.test(errorMessage)) {
      message.error('扫码入库失败,请检查批次数量和库位配置');
      message.error('扫码入库失败,请检查批次数量和仓库配置');
    } else {
      message.error(errorMessage);
    }
@@ -262,23 +217,21 @@
        <a-divider />
        <a-form layout="vertical" class="config-form">
          <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>{{ 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="device-state"><span class="online-badge"><span class="status-dot" aria-hidden="true" /><span>设备已就绪</span></span><span class="muted">15 ç§’自动刷新</span></div>
        <div class="status-list"><div><span>生产产线</span><strong>{{ lineCode || '未配置' }}</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>
    <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="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 :data-source="records" :pagination="false" :scroll="{ x: 1250 }" row-key="key" size="middle">
        <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="palletCode" title="托盘码"><template #default="{ record }">{{ record.palletCode || '-' }}</template></a-table-column><a-table-column key="batchCode" title="批次码"><template #default="{ record }">{{ record.batchCode || '-' }}</template></a-table-column><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>
src/views/mes/pro/scanEvent/index.vue
@@ -20,8 +20,6 @@
const code = ref('');
const deviceCode = ref('');
const lineCode = ref('');
const locationId = ref<number>();
const areaId = ref<number>();
const loading = ref(false);
const refreshing = ref(false);
const records = ref<ScanRecord[]>([]);
@@ -31,7 +29,7 @@
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(deviceCode.value.trim()));
const pageRoot = ref<HTMLElement>();
let animationContext: gsap.Context | undefined;
let feedbackTimeline: gsap.core.Timeline | undefined;
@@ -56,21 +54,20 @@
    message.warning('请先填写设备编码,再扫描袋码');
    return;
  }
  if (!locationId.value || !areaId.value) {
    message.warning('请先配置 WIP æš‚存库区和库位');
    return;
  }
  loading.value = true;
  try {
    await inboundScanEvent({
    const result = await inboundScanEvent({
      eventId: `${deviceCode.value.trim()}-${Date.now()}`,
      bagCode,
      quantity: 1,
      deviceCode: deviceCode.value.trim(),
      lineCode: lineCode.value.trim() || undefined,
      locationId: locationId.value,
      areaId: areaId.value,
    });
    if (result.status === 'FAILED') {
      message.error(result.errorMessage || `袋码 ${bagCode} å…¥åº“失败`);
      await refresh();
      return;
    }
    message.success(`袋码 ${bagCode} å·²å®Œæˆå…¥åº“`);
    if (animationContext && pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      feedbackTimeline?.kill();
@@ -172,15 +169,13 @@
        <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>
      </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="status-list"><div><span>当前设备</span><strong>{{ deviceCode || '未配置' }}</strong></div><div><span>生产产线</span><strong>{{ lineCode || '未配置' }}</strong></div><div><span>最近入库时间</span><strong>{{ latest?.eventTime || '-' }}</strong></div><div><span>最近生产批次</span><strong>{{ latest?.batchCode || '-' }}</strong></div></div>
        <div class="sync-box"><IconifyIcon icon="ant-design:check-circle-outlined" /><div><strong>仓储系统同步正常</strong><span>扫码事件与库存事务实时写入</span></div></div>
      </a-card>
    </section>
src/views/mes/qc/batchtrace/modules/SalesTraceDetails.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { ref } from 'vue';
import type { MesTraceBatchApi } from '#/api/mes/trace/batch';
import { getTransactionPage, type MesWmTransactionApi } from '#/api/mes/wm/transaction';
const props = defineProps<{ sales: MesTraceBatchApi.SalesTarget; batchCode?: string }>();
const rows = ref<MesWmTransactionApi.Transaction[]>([]);
const page = ref(0), total = ref(0), loading = ref(false), error = ref('');
async function load(reset = false) {
  if (loading.value || !props.sales.salesCode || !props.batchCode) return;
  loading.value = true; error.value = '';
  try {
    const next = reset ? 1 : page.value + 1;
    const result = await getTransactionPage({ pageNo: next, pageSize: 20, bizType: 119, type: 2,
      bizCodeExact: props.sales.salesCode, batchCodeExact: props.batchCode, shipmentId: props.sales.shipmentId });
    rows.value = reset ? result.list : [...rows.value, ...result.list];
    total.value = result.total; page.value = next;
  } catch { error.value = '出库流水加载失败,请检查流水查询权限后重试。'; }
  finally { loading.value = false; }
}
function toggle(event: Event) {
  if ((event.target as HTMLDetailsElement).open && !page.value) void load(true);
}
</script>
<template>
  <div class="mt-1 text-sm">出库单:{{ sales.salesCode || '未记录' }} Â· å®¢æˆ·ï¼š{{ sales.clientName || '未记录' }} Â· æ‰¹æ¬¡ï¼š{{ batchCode || '未记录' }}</div>
  <details class="mt-2 text-sm" @toggle="toggle">
    <summary class="cursor-pointer">查看出库托盘与袋码</summary>
    <p v-if="!sales.salesCode || !batchCode">缺少出库单号或批次号,无法查询明细。</p>
    <p v-if="loading" role="status">正在加载出库流水…</p>
    <p v-if="error" role="alert">{{ error }} <button type="button" @click="load(true)">重试</button></p>
    <p v-if="page && !rows.length">暂无对应出库流水。</p>
    <div v-for="row in rows" :key="row.id" class="mt-2 border-b pb-2">
      <div>托盘:{{ row.palletCode || '未记录' }} Â· è¢‹ç ï¼š{{ row.bagCode || '未记录' }}</div>
      <div>数量:{{ row.quantity ?? '—' }} Â· å‡ºåº“时间:{{ row.transactionTime || '未记录' }}</div>
    </div>
    <button v-if="rows.length < total" type="button" :disabled="loading" @click="load()">加载更多({{ rows.length }} / {{ total }})</button>
  </details>
</template>
src/views/mes/qc/batchtrace/modules/TraceTimeline.vue
@@ -1,19 +1,28 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { MesTraceBatchApi } from '#/api/mes/trace/batch';
import SalesTraceDetails from './SalesTraceDetails.vue';
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;
const timeline = computed<(MesTraceBatchApi.Timeline & { sales?: MesTraceBatchApi.SalesTarget })[]>(() => {
  if (props.detail.timeline?.length) {
    const events: (MesTraceBatchApi.Timeline & { sales?: MesTraceBatchApi.SalesTarget })[] = props.detail.timeline.filter(item => item.eventType !== 'OUTBOUND' || item.stage !== '销售');
    if (props.detail.salesTargets?.length) {
      events.push(...props.detail.salesTargets.map(sales => ({ stage: '销售', eventType: 'OUTBOUND', eventName: '销售出库', eventTime: sales.salesDate, quantity: sales.quantity, sales })));
    } else {
      events.push(...props.detail.timeline.filter(item => item.eventType === 'OUTBOUND' && item.stage === '销售'));
    }
    return events.sort((a, b) => (a.eventTime || '\uffff').localeCompare(b.eventTime || '\uffff'));
  }
  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.salesTargets ?? []).map((item) => ({ stage: '销售', eventType: 'OUTBOUND', eventName: '销售出库', eventTime: item.salesDate, quantity: item.quantity, sales: item })),
    ...(props.detail.antiFake?.scanCount ? [{ stage: '消费', eventType: 'CONSUMER_SCANNED', eventName: '消费者验证', eventTime: props.detail.antiFake.lastScanTime, quantity: props.detail.antiFake.scanCount, status: `累计扫码 ${props.detail.antiFake.scanCount} æ¬¡` }] : []),
  ];
});
@@ -30,6 +39,7 @@
          {{ item.stage || '未分类' }} Â· æ•°é‡ï¼š{{ item.quantity ?? '—' }} Â· {{ formatTime(item.eventTime) }}
          <span v-if="item.status"> Â· {{ item.status }}</span>
        </div>
        <SalesTraceDetails v-if="item.sales" :sales="item.sales" :batch-code="detail.batchCode" />
      </a-timeline-item>
      <a-empty v-if="!timeline.length" description="暂无追溯事件" />
    </a-timeline>
src/views/mes/qc/seedQuality/index.vue
@@ -11,7 +11,6 @@
import {
  resolveSeedQuality,
  submitSeedQualityGroup,
  getSeedIndicatorRanges,
} from '#/api/mes/qc/seedQuality';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
@@ -54,29 +53,27 @@
const qualifiedWarehouses = ref<MesWmWarehouseApi.Warehouse[]>([]);
const qualifiedWarehouseId = ref<number>();
const indicators = ref<IndicatorForm[]>([
  { code: 'purity', label: '纯度', unit: '%' },
  { code: 'cleanliness', label: '净度', unit: '%' },
  { code: 'germination_rate', label: '发芽率', unit: '%' },
  { code: 'moisture', label: '水分', unit: '%' },
]);
const rangesReady = computed(() => indicators.value.every((item) =>
const indicators = ref<IndicatorForm[]>([]);
const rangesReady = computed(() => indicators.value.length > 0 && indicators.value.every((item) =>
  item.minValue !== undefined && item.maxValue !== undefined));
async function loadRanges() {
  const ranges = await getSeedIndicatorRanges();
  for (const item of indicators.value) {
    const range = ranges.find((row) => row.indicatorCode === item.code);
    item.minValue = range?.minValue === undefined ? undefined : Number(range.minValue);
    item.maxValue = range?.maxValue === undefined ? undefined : Number(range.maxValue);
  }
/** è¯†åˆ«å¯¹è±¡åŽï¼ŒæŒ‰äº§å“ç”Ÿæ•ˆçš„质检项重建录入项 */
function applyIndicators(scan: SeedQualityScan) {
  indicators.value = (scan.indicators ?? []).map((item) => ({
    code: item.code,
    label: item.name || item.code,
    unit: item.unit || '',
    value: undefined,
    minValue: item.minValue === null || item.minValue === undefined ? undefined : Number(item.minValue),
    maxValue: item.maxValue === null || item.maxValue === undefined ? undefined : Number(item.maxValue),
  }));
}
const pendingCount = computed(() => target.value?.pendingBagIds?.length ?? 0);
const completedCount = computed(() => target.value?.completedBagIds?.length ?? 0);
const enteredCount = computed(() => indicators.value.filter((item) => item.value !== undefined && item.value !== null).length);
const passedCount = computed(() => indicators.value.filter(isPassed).length);
const allEntered = computed(() => indicators.value.every((item) =>
const allEntered = computed(() => indicators.value.length > 0 && indicators.value.every((item) =>
  item.value !== undefined && item.value !== null
  && item.minValue !== undefined && item.minValue !== null
  && item.maxValue !== undefined && item.maxValue !== null));
@@ -142,8 +139,7 @@
  resolving.value = true;
  submittedCount.value = undefined;
  try {
    await loadRanges().catch(() => undefined);
    const data = await resolveSeedQuality(value, scanType.value);
    const data = await resolveSeedQuality(value, scanType.value, 1);
    if (data.canceled) {
      target.value = undefined;
      message.error('该质检对象已作废,不能质检');
@@ -159,7 +155,13 @@
      message.error('该对象没有待质检袋码,请在质检记录中查看已有结果');
      return;
    }
    if (!data.indicators?.length) {
      target.value = undefined;
      message.error('该产品尚未配置质检项,请先在「质检项合格范围配置」中维护');
      return;
    }
    target.value = data;
    applyIndicators(data);
    resetIndicators();
    message.success(`${SCAN_TYPE_LABEL[scanType.value]} ${data.code} å·²è¯†åˆ«ï¼Œæœ¬æ¬¡å¾…检 ${data.pendingBagIds.length} è¢‹`);
  } catch (error) {
@@ -177,7 +179,7 @@
    return;
  }
  if (!allEntered.value) {
    message.warning('请填写四项指标的实测值和本次合格上下限');
    message.warning('请填写全部质检项的实测值和本次合格上下限');
    return;
  }
  if (indicators.value.some((item) => item.minValue === undefined || item.maxValue === undefined || item.minValue > item.maxValue)) {
@@ -201,8 +203,8 @@
    Modal.confirm({
      title: overallPassed.value ? '确认提交合格结果?' : '确认提交不合格结果?',
      content: overallPassed.value
        ? `本次 ${count} è¢‹å°†ç»Ÿä¸€è®°å½•为初检合格,待初检审核通过后复检;复检审核通过后才转入合格库。${scopeNote}`
        : `本次 ${count} è¢‹å°†è®°å½•为初检不合格,保留暂存库存;初检审核后决定是否进入复检。${scopeNote}`,
        ? `本次 ${count} è¢‹å°†ç»Ÿä¸€è®°å½•为初检合格,进入部门审批;双线审批和公司质检审批完成后才转入合格库。${scopeNote}`
        : `本次 ${count} è¢‹å°†è®°å½•为初检不合格,保留暂存库存并进入复检。${scopeNote}`,
      okText: '确认提交',
      cancelText: '返回修改',
      okButtonProps: { danger: !overallPassed.value },
@@ -230,7 +232,7 @@
      })),
    });
    submittedCount.value = result.processedCount;
    message.success(`本次 ${result.processedCount} è¢‹å·²æäº¤åˆæ£€ï¼Œå¾…初检审核`);
    message.success(`本次 ${result.processedCount} è¢‹è´¨æ£€å·²æäº¤ï¼Œå·²åŒæ­¥ç”ŸæˆåŠ å·¥éƒ¨ã€é”€å”®éƒ¨ä¸¤æ¡è´¨æ£€è®°å½•`);
    if (pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      gsap.fromTo('.result-card', { scale: 0.97, autoAlpha: 0.5 }, { scale: 1, autoAlpha: 1, duration: 0.35, ease: 'power2.out' });
    }
@@ -246,11 +248,6 @@
}
onMounted(async () => {
  try {
    await loadRanges();
  } catch (error) {
    message.error(errorText(error));
  }
  try {
    const warehouses = await getWarehouseSimpleList();
    qualifiedWarehouses.value = (warehouses ?? []).filter(
@@ -282,15 +279,15 @@
      <div>
        <div class="eyebrow">MES / è´¨é‡ç®¡ç†</div>
        <h1>种子质检</h1>
        <p>识别待检范围并录入四项指标;初检审核后复检,复检审核后入库或处置</p>
        <p>一次提交同时生成加工部、销售部两条质检记录;初检不合格先复检,双线部门审批后进入公司质检审批</p>
      </div>
      <div class="header-badge"><IconifyIcon icon="ep:checked" /> è¢‹ç  / æ‰˜ç›˜ç  / æ‰¹æ¬¡ç </div>
    </header>
    <section class="summary-grid" aria-label="质检摘要">
      <div class="summary-card blue"><IconifyIcon icon="ep:document-checked" /><div><span>检验项目</span><strong>4</strong><small>纯度、净度、发芽率、水分</small></div></div>
      <div class="summary-card blue"><IconifyIcon icon="ep:document-checked" /><div><span>检验项目</span><strong>{{ indicators.length }}</strong><small>{{ indicators.length ? indicators.map((item) => item.label).join('、') : '识别后按产品带出' }}</small></div></div>
      <div class="summary-card" :class="target ? 'green' : 'blue'"><IconifyIcon icon="ep:box" /><div><span>本次待检</span><strong>{{ target ? `${pendingCount} è¢‹` : '待识别' }}</strong><small>{{ target ? `${SCAN_TYPE_LABEL[scanType]} ${target.code}` : `请选择类型后识别${SCAN_TYPE_LABEL[scanType]}` }}</small></div></div>
      <div class="summary-card" :class="overallPassed ? 'green' : allEntered ? 'red' : 'blue'"><IconifyIcon :icon="overallPassed ? 'ep:circle-check-filled' : allEntered ? 'ep:warning-filled' : 'ep:edit-pen'" /><div><span>总体结论</span><strong>{{ overallPassed ? '合格' : allEntered ? '不合格' : '待录入' }}</strong><small>{{ overallPassed ? '可提交合格结果' : allEntered ? '请填写不合格原因' : `已录入 ${enteredCount} / 4 é¡¹` }}</small></div></div>
      <div class="summary-card" :class="overallPassed ? 'green' : allEntered ? 'red' : 'blue'"><IconifyIcon :icon="overallPassed ? 'ep:circle-check-filled' : allEntered ? 'ep:warning-filled' : 'ep:edit-pen'" /><div><span>总体结论</span><strong>{{ overallPassed ? '合格' : allEntered ? '不合格' : '待录入' }}</strong><small>{{ overallPassed ? '可提交合格结果' : allEntered ? '请填写不合格原因' : `已录入 ${enteredCount} / ${indicators.length} é¡¹` }}</small></div></div>
    </section>
    <section class="content-grid">
@@ -334,7 +331,7 @@
            </a-form-item>
          </div>
          <p v-if="!qualifiedWarehouses.length" class="target-warning">暂无可用合格库,请联系管理员配置后再提交质检。</p>
          <a-alert v-if="!rangesReady" class="target-empty" type="info" show-icon message="本次请手动填写合格范围" description="管理员尚未配置完整默认范围;仍可手动填写四项指标的合格上下限。" />
          <a-alert v-if="target && !rangesReady" class="target-empty" type="info" show-icon message="本次请手动填写合格范围" description="该产品部分质检项未配置默认范围,仍可手动填写本次合格上下限。" />
          <a-divider orientation="left">指标结果</a-divider>
          <div class="indicator-list">
@@ -342,9 +339,9 @@
              <div class="indicator-name"><span class="indicator-dot" :class="isPassed(item) ? 'pass' : 'fail'" /><strong>{{ item.label }}</strong><small>{{ item.unit }}</small></div>
              <a-input-number v-model:value="item.value" class="indicator-input" :disabled="loading" :min="0" :precision="3" placeholder="实测值" />
              <span class="range-separator">范围</span>
              <a-input-number v-model:value="item.minValue" class="range-input" :disabled="loading" :min="0" :max="100" :precision="3" placeholder="下限" />
              <a-input-number v-model:value="item.minValue" class="range-input" :disabled="loading" :min="0" :precision="3" placeholder="下限" />
              <span>—</span>
              <a-input-number v-model:value="item.maxValue" class="range-input" :disabled="loading" :min="0" :max="100" :precision="3" placeholder="上限" />
              <a-input-number v-model:value="item.maxValue" class="range-input" :disabled="loading" :min="0" :precision="3" placeholder="上限" />
              <a-tag :color="!allEntered ? 'default' : isPassed(item) ? 'success' : 'error'">{{ !allEntered ? '待录入' : isPassed(item) ? '合格' : '超出范围' }}</a-tag>
            </div>
          </div>
@@ -356,9 +353,9 @@
      <a-card class="result-card" :bordered="false">
        <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><IconifyIcon icon="ep:check" /><span>质检员与检验时间由服务端记录</span></div></div>
        <a-alert message="复检审核完成后流转库存" description="初检及复检期间袋码留在暂存库。初检审核通过后进入复检;复检合格并审核通过才转入合格库,复检不合格经确认后才报废。" type="info" show-icon />
        <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} / ${indicators.length} é¡¹å®žæµ‹å€¼` }}</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><IconifyIcon icon="ep:check" /><span>不合格结果需保留原因</span></div><div><IconifyIcon icon="ep:check" /><span>质检员与检验时间由服务端记录</span></div></div>
        <a-alert message="双线审批完成后进入公司质检审批" description="初检不合格先复检,期间袋码留在暂存库。两条质检线各自完成部门审批后发起公司质检审批;公司审批通过才转入合格库,任一条线不合格则整袋报废。" type="info" show-icon />
        <div v-if="submittedCount" class="success-note"><IconifyIcon icon="ep:success-filled" /><span>最近提交质检:<strong>{{ submittedCount }} è¢‹</strong></span></div>
      </a-card>
    </section>
src/views/mes/qc/seedQuality/process-detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,808 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
  getSeedQualityActions,
  getSeedQualityIndicators,
  getSeedQualityPage,
  resolveSeedReturnQuality,
  type SeedQualityAction,
  type SeedQualityIndicator,
  type SeedQualityRecord,
  type SeedReturnQualityInfo,
} from '#/api/mes/qc/seedQuality';
defineOptions({ name: 'MesQcSeedQualityProcessDetail' });
const props = defineProps<{
  /** æµç¨‹ä¸šåŠ¡æ ‡è¯†ï¼šéƒ¨é—¨å®¡æ‰¹ qcLine#batchCode,公司审批为生产批次号,退货审批为 returnSalesId */
  id?: string;
  /** æµç¨‹å®šä¹‰æ ‡è¯†ï¼Œç”¨äºŽåŒºåˆ†éƒ¨é—¨å®¡æ‰¹ / å…¬å¸å®¡æ‰¹ / é€€è´§å®¡æ‰¹ */
  processDefinitionKey?: string;
}>();
const LINE_PROCESS_KEY = 'mes_seed_quality_line_approve';
const COMPANY_PROCESS_KEY = 'mes_seed_quality_company_approve';
const RETURN_PROCESS_KEY = 'mes_seed_return_approve';
type DetailMode = 'line' | 'company' | 'return' | 'unknown';
const loading = ref(false);
const errorMessage = ref('');
const mode = ref<DetailMode>('unknown');
const records = ref<SeedQualityRecord[]>([]);
const returnInfo = ref<SeedReturnQualityInfo>();
const parsedLine = ref<number>();
const parsedBatchCode = ref('');
const parsedBagCode = ref('');
const companyByBatch = ref(true);
const pageNo = ref(1);
const pageSize = ref(20);
const pageTotal = ref(0);
const indicatorCache = ref<Record<number, SeedQualityIndicator[]>>({});
const actionCache = ref<Record<number, SeedQualityAction[]>>({});
const expandedKeys = ref<number[]>([]);
const workflowLabels: Record<number, string> = {
  10: '待复检',
  20: '待部门审批',
  25: '待部门不合格审批',
  30: '待公司质检审批',
  40: '已放行',
  50: '初检驳回待重检',
  60: '待公司质检不合格处理',
  70: '已报废',
};
const qcLineLabels: Record<number, string> = { 0: '未区分', 1: '加工部', 2: '销售部' };
const resultLabels: Record<number, string> = { 0: '处理中', 1: '合格', 2: '不合格' };
const actionLabels: Record<string, string> = {
  INITIAL: '初检',
  INITIAL_RECHECK: '重新初检',
  RETEST: '复检',
  PROCESS_APPROVE: '初检审核通过',
  PROCESS_REJECT: '初检审核驳回',
  COMPANY_APPROVE: '复检审核通过',
  COMPANY_REJECT: '复检审核驳回',
};
const indicatorLabels: Record<string, string> = {
  purity: '纯度',
  cleanliness: '净度',
  germination_rate: '发芽率',
  moisture: '水分',
};
const businessKey = computed(() => (props.id ?? '').trim());
const displayRecords = computed<SeedQualityRecord[]>(() => {
  if (mode.value !== 'return') {
    return records.value;
  }
  const info = returnInfo.value;
  if (!info?.qualityId) {
    return [];
  }
  return [
    {
      id: info.qualityId,
      code: info.returnSalesCode || `退货单 ${info.returnSalesId}`,
      bizType: 2,
      batchCode: info.batchCode,
      result: info.result ?? 0,
      workflowStatus: info.workflowStatus ?? 0,
      returnSalesId: info.returnSalesId,
      returnSalesCode: info.returnSalesCode,
    },
  ];
});
const modeLabel = computed(() => {
  if (mode.value === 'line') return '种子质检 Â· éƒ¨é—¨å®¡æ‰¹';
  if (mode.value === 'company') return '种子质检 Â· å…¬å¸è´¨æ£€å®¡æ‰¹';
  if (mode.value === 'return') return '经销商退货 Â· è´¨æ£€å®¡æ‰¹';
  return '种子质检';
});
const summaryTitle = computed(() => {
  if (mode.value === 'line') {
    return `生产批次 ${parsedBatchCode.value || '—'} Â· ${qcLineLabels[parsedLine.value ?? 0] || '未知质检线'}`;
  }
  if (mode.value === 'company') {
    return companyByBatch.value
      ? `生产批次 ${parsedBatchCode.value || '—'}`
      : `袋码 ${parsedBagCode.value || '—'}`;
  }
  if (mode.value === 'return') {
    const info = returnInfo.value;
    return info?.returnSalesCode ? `退货单 ${info.returnSalesCode}` : '经销商退货质检';
  }
  return '质检详情';
});
const summarySubtitle = computed(() => {
  if (mode.value === 'line') {
    return `共 ${pageTotal.value} æ¡è´¨æ£€è®°å½•,指标与流程留痕可展开查看`;
  }
  if (mode.value === 'company') {
    return `本批次共 ${pageTotal.value} æ¡è´¨æ£€è®°å½•,每个袋的加工部、销售部两条线均合格才放行入库`;
  }
  if (mode.value === 'return') {
    return returnInfo.value?.batchCode ? `生产批次 ${returnInfo.value.batchCode}` : '退货质检记录';
  }
  return '';
});
const passCount = computed(() => records.value.filter((item) => item.result === 1).length);
const failCount = computed(() => records.value.filter((item) => item.result === 2).length);
const processingCount = computed(() => records.value.filter((item) => !item.result).length);
const tablePagination = computed(() => {
  if (
    (mode.value !== 'line' && mode.value !== 'company') ||
    pageTotal.value <= pageSize.value
  ) {
    return false as const;
  }
  return {
    current: pageNo.value,
    pageSize: pageSize.value,
    pageSizeOptions: ['20', '50', '100', '200'],
    showSizeChanger: true,
    showTotal: (count: number) => `共 ${count} æ¡`,
    total: pageTotal.value,
    onChange: (page: number, size: number) => {
      pageSize.value = size;
      if (mode.value === 'line') {
        void loadLine(businessKey.value, page);
      } else if (mode.value === 'company') {
        void loadCompany(businessKey.value, page);
      }
    },
  };
});
function detectMode(): DetailMode {
  const key = props.processDefinitionKey;
  if (key === LINE_PROCESS_KEY) return 'line';
  if (key === COMPANY_PROCESS_KEY) return 'company';
  if (key === RETURN_PROCESS_KEY) return 'return';
  if (businessKey.value.includes('#')) return 'line';
  return 'unknown';
}
async function loadDetails() {
  const key = businessKey.value;
  if (!key) {
    errorMessage.value = '流程缺少业务标识,无法展示质检详情';
    return;
  }
  loading.value = true;
  errorMessage.value = '';
  records.value = [];
  returnInfo.value = undefined;
  indicatorCache.value = {};
  actionCache.value = {};
  expandedKeys.value = [];
  pageNo.value = 1;
  pageTotal.value = 0;
  try {
    const detected = detectMode();
    if (detected === 'line') {
      mode.value = 'line';
      await loadLine(key);
    } else if (detected === 'return') {
      mode.value = 'return';
      await loadReturn(key);
    } else if (detected === 'company') {
      mode.value = 'company';
      await loadCompany(key);
    } else {
      await loadCompany(key);
      if (!records.value.length && /^\d+$/.test(key)) {
        mode.value = 'return';
        await loadReturn(key);
      } else {
        mode.value = 'company';
      }
    }
  } catch (error) {
    errorMessage.value = readError(error);
  } finally {
    loading.value = false;
  }
}
async function loadLine(key: string, page = pageNo.value) {
  const index = key.indexOf('#');
  const line = Number(key.slice(0, index));
  const batchCode = key.slice(index + 1);
  if (!Number.isFinite(line) || !batchCode) {
    throw new Error('审批业务标识格式异常,应为 è´¨æ£€çº¿#批次号');
  }
  parsedLine.value = line;
  parsedBatchCode.value = batchCode;
  const resp = await getSeedQualityPage({
    pageNo: page,
    pageSize: pageSize.value,
    exactBatchCode: batchCode,
    qcLine: line,
  });
  records.value = resp.list ?? [];
  pageTotal.value = resp.total ?? 0;
  pageNo.value = page;
}
async function loadCompany(key: string, page = pageNo.value) {
  // æ–°æµç¨‹æŒ‰ç”Ÿäº§æ‰¹æ¬¡å‘起公司审批;历史数据按袋码发起,做一次兼容回退
  companyByBatch.value = true;
  parsedBatchCode.value = key;
  parsedBagCode.value = '';
  let resp = await getSeedQualityPage({
    pageNo: page,
    pageSize: pageSize.value,
    exactBatchCode: key,
    bizType: 1,
  });
  let list = resp.list ?? [];
  let total = resp.total ?? 0;
  if (!list.length && page === 1) {
    companyByBatch.value = false;
    parsedBatchCode.value = '';
    parsedBagCode.value = key;
    resp = await getSeedQualityPage({
      pageNo: 1,
      pageSize: pageSize.value,
      code: key,
      bizType: 1,
    });
    list = (resp.list ?? []).filter((item) => item.code === key);
    total = list.length;
  }
  records.value = list;
  pageTotal.value = total;
  pageNo.value = page;
  if (list.length) {
    const first = list[0];
    if (first) {
      expandedKeys.value = [first.id];
    }
    await Promise.all(list.slice(0, 5).map((item) => loadIndicators(item.id)));
  }
}
async function loadReturn(key: string) {
  const returnSalesId = Number(key);
  if (!Number.isFinite(returnSalesId)) {
    throw new Error('退货审批业务标识不是有效的销售退货单编号');
  }
  returnInfo.value = await resolveSeedReturnQuality(returnSalesId);
  const qualityId = returnInfo.value?.qualityId;
  if (qualityId) {
    expandedKeys.value = [qualityId];
    await Promise.all([loadIndicators(qualityId), loadActions(qualityId)]);
  }
}
async function loadIndicators(id: number) {
  if (indicatorCache.value[id]) {
    return;
  }
  try {
    const list = await getSeedQualityIndicators(id);
    indicatorCache.value = { ...indicatorCache.value, [id]: list ?? [] };
  } catch {
    indicatorCache.value = { ...indicatorCache.value, [id]: [] };
  }
}
async function loadActions(id: number) {
  if (actionCache.value[id]) {
    return;
  }
  try {
    const list = await getSeedQualityActions(id);
    actionCache.value = { ...actionCache.value, [id]: list ?? [] };
  } catch {
    actionCache.value = { ...actionCache.value, [id]: [] };
  }
}
async function handleExpand(expanded: boolean, record: SeedQualityRecord) {
  if (!expanded) {
    expandedKeys.value = [];
    return;
  }
  expandedKeys.value = [record.id];
  await Promise.all([loadIndicators(record.id), loadActions(record.id)]);
}
function readError(error: unknown) {
  const response = error as {
    data?: { msg?: string };
    message?: string;
    msg?: string;
    response?: { data?: { msg?: string } };
  };
  return (
    response?.response?.data?.msg ||
    response?.data?.msg ||
    response?.msg ||
    response?.message ||
    '读取质检详情失败'
  );
}
function statusLabel(record: SeedQualityRecord) {
  if (record.bizType === 2) {
    if (record.workflowStatus === 10) return '待复检';
    if (record.workflowStatus === 20) return '待销售部领导审批';
    if (record.workflowStatus === 40) return '退货质检通过';
    if (record.workflowStatus === 70) return '退货质检不通过';
  }
  return workflowLabels[record.workflowStatus] || '未知状态';
}
function statusColor(record: SeedQualityRecord) {
  if (record.workflowStatus === 40) return 'green';
  if (record.workflowStatus === 70) return 'red';
  if (record.workflowStatus === 60) return 'orange';
  return 'blue';
}
function resultColor(result?: number) {
  if (result === 1) return 'success';
  if (result === 2) return 'error';
  return 'warning';
}
function qcLineColor(line?: number) {
  if (line === 1) return 'blue';
  if (line === 2) return 'purple';
  return 'default';
}
function formatDateTime(value?: string) {
  return value ? new Date(value).toLocaleString() : '—';
}
function indicatorLabel(item: SeedQualityIndicator) {
  return item.indicatorName || indicatorLabels[item.indicatorCode] || item.indicatorCode;
}
function indicatorPassed(item: SeedQualityIndicator) {
  const value = Number(item.value);
  return (
    Number.isFinite(value) &&
    value >= Number(item.minValue) &&
    value <= Number(item.maxValue)
  );
}
function actionColor(action: string) {
  if (action.includes('REJECT')) return 'red';
  if (action.includes('APPROVE')) return 'green';
  return 'blue';
}
function actionIndicators(action: SeedQualityAction) {
  if (!action.indicatorsJson) {
    return [] as Array<{ code: string; maxValue: number; minValue: number; value: number }>;
  }
  try {
    const parsed: unknown = JSON.parse(action.indicatorsJson);
    return Array.isArray(parsed)
      ? (parsed as Array<{ code: string; maxValue: number; minValue: number; value: number }>)
      : [];
  } catch {
    return [] as Array<{ code: string; maxValue: number; minValue: number; value: number }>;
  }
}
watch(
  () => [props.id, props.processDefinitionKey],
  () => {
    void loadDetails();
  },
  { immediate: true },
);
</script>
<template>
  <a-spin :spinning="loading">
    <div class="seed-process-detail">
      <a-alert
        v-if="errorMessage"
        type="error"
        show-icon
        :message="errorMessage"
      />
      <template v-else-if="displayRecords.length || returnInfo">
        <div class="summary-bar">
          <div class="summary-main">
            <span class="eyebrow">
              <IconifyIcon icon="ant-design:experiment-outlined" />
              {{ modeLabel }}
            </span>
            <strong class="summary-title">{{ summaryTitle }}</strong>
            <span class="summary-sub">{{ summarySubtitle }}</span>
          </div>
          <div v-if="mode !== 'return'" class="summary-counts">
            <span class="count pass">本页合格 {{ passCount }}</span>
            <span class="count fail">本页不合格 {{ failCount }}</span>
            <span class="count pending">本页流程中 {{ processingCount }}</span>
          </div>
        </div>
        <a-table
          :data-source="displayRecords"
          :expanded-row-keys="expandedKeys"
          :pagination="tablePagination"
          :scroll="{ x: 720 }"
          row-key="id"
          size="small"
          @expand="handleExpand"
        >
          <template #expandedRowRender="{ record }">
            <div class="expand-body">
              <div class="expand-section">
                <div class="expand-title">质检指标</div>
                <div
                  v-if="indicatorCache[record.id]?.length"
                  class="indicator-grid"
                >
                  <div
                    v-for="item in indicatorCache[record.id]"
                    :key="item.indicatorCode"
                    class="indicator-item"
                  >
                    <span class="indicator-name">{{ indicatorLabel(item) }}</span>
                    <strong
                      class="indicator-value"
                      :class="{ failed: !indicatorPassed(item) }"
                    >
                      {{ item.value ?? '—' }}
                      <small v-if="item.unit">{{ item.unit }}</small>
                    </strong>
                    <span class="indicator-range">
                      åˆæ ¼ {{ item.minValue }} ~ {{ item.maxValue }}
                    </span>
                  </div>
                </div>
                <div v-else class="expand-empty">暂无指标记录</div>
              </div>
              <div class="expand-section">
                <div class="expand-title">流程留痕</div>
                <div
                  v-if="actionCache[record.id]?.length"
                  class="action-list"
                >
                  <div
                    v-for="action in actionCache[record.id]"
                    :key="action.id"
                    class="action-item"
                  >
                    <div class="action-head">
                      <a-tag :color="actionColor(action.action)">
                        {{ actionLabels[action.action] || action.action }}
                      </a-tag>
                      <span class="action-meta">
                        {{ formatDateTime(action.createTime) }} Â· æ“ä½œäºº
                        {{ action.operatorName || action.operatorId || '—' }}
                      </span>
                    </div>
                    <p v-if="action.opinion" class="action-opinion">
                      {{ action.opinion }}
                    </p>
                    <div
                      v-if="actionIndicators(action).length"
                      class="action-indicators"
                    >
                      <span
                        v-for="indicator in actionIndicators(action)"
                        :key="indicator.code"
                        class="action-indicator"
                      >
                        {{ indicatorLabels[indicator.code] || indicator.code }}
                        <strong>{{ indicator.value }}</strong>
                        <em>{{ indicator.minValue }}~{{ indicator.maxValue }}</em>
                      </span>
                    </div>
                  </div>
                </div>
                <div v-else class="expand-empty">暂无流程留痕</div>
              </div>
            </div>
          </template>
          <a-table-column key="code" title="袋码 / å•号" data-index="code">
            <template #default="{ record }">
              <span class="mono strong">{{ record.code }}</span>
            </template>
          </a-table-column>
          <a-table-column
            v-if="mode !== 'return'"
            key="qcLine"
            title="质检线"
            width="90"
          >
            <template #default="{ record }">
              <a-tag :color="qcLineColor(record.qcLine)">
                {{ qcLineLabels[record.qcLine ?? 0] || '未区分' }}
              </a-tag>
            </template>
          </a-table-column>
          <a-table-column key="result" title="检验结果" width="90">
            <template #default="{ record }">
              <a-tag :color="resultColor(record.result)">
                {{ resultLabels[record.result] || '处理中' }}
              </a-tag>
            </template>
          </a-table-column>
          <a-table-column key="workflowStatus" title="流程状态" width="150">
            <template #default="{ record }">
              <a-tag :color="statusColor(record)">
                {{ statusLabel(record) }}
              </a-tag>
            </template>
          </a-table-column>
          <a-table-column
            v-if="mode !== 'return'"
            key="reason"
            title="不合格原因"
            data-index="reason"
            ellipsis
          >
            <template #default="{ record }">
              <span v-if="record.reason">{{ record.reason }}</span>
              <span v-else class="muted">—</span>
            </template>
          </a-table-column>
          <a-table-column key="inspectedAt" title="检验时间" width="170">
            <template #default="{ record }">
              <span class="mono muted">
                {{ formatDateTime(record.inspectedAt) }}
              </span>
            </template>
          </a-table-column>
        </a-table>
        <div v-if="mode === 'return'" class="return-actions">
          <span class="muted">审批结论由销售部领导在流程中确认。</span>
        </div>
      </template>
      <a-empty v-else description="未找到关联的质检记录" />
    </div>
  </a-spin>
</template>
<style scoped>
.seed-process-detail {
  padding: 2px 0;
}
.summary-bar {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 14px;
  padding: 14px 16px;
  border: 1px solid #e3e9f2;
  border-radius: 10px;
  background: linear-gradient(135deg, #f8fbff 0%, #ffffff 70%);
}
.summary-main {
  display: flex;
  flex-direction: column;
  gap: 5px;
  min-width: 0;
}
.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: #1677ff;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.04em;
}
.summary-title {
  color: #172033;
  font-size: 16px;
  font-weight: 700;
}
.summary-sub {
  color: #718096;
  font-size: 12px;
  line-height: 1.6;
}
.summary-counts {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 6px;
  white-space: nowrap;
}
.count {
  padding: 2px 10px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  line-height: 20px;
}
.count.pass {
  color: #16a34a;
  background: #ecfdf3;
}
.count.fail {
  color: #dc2626;
  background: #fef2f2;
}
.count.pending {
  color: #d97706;
  background: #fffbeb;
}
.expand-body {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 8px 4px 12px;
}
.expand-section {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.expand-title {
  color: #344054;
  font-size: 13px;
  font-weight: 700;
}
.expand-empty {
  color: #9aa5b5;
  font-size: 12px;
}
.indicator-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 8px;
}
.indicator-item {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 8px 12px;
  border: 1px solid #e3e9f2;
  border-radius: 6px;
  background: #fff;
  font-size: 13px;
}
.indicator-name {
  color: #5b6b80;
}
.indicator-value {
  color: #172033;
  font-size: 15px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.indicator-value.failed {
  color: #dc2626;
}
.indicator-value small {
  margin-left: 2px;
  color: #8793a5;
  font-size: 11px;
  font-weight: 400;
}
.indicator-range {
  margin-left: auto;
  color: #667085;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
}
.action-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.action-item {
  padding: 8px 12px;
  border-left: 3px solid #1677ff;
  border-radius: 4px;
  background: #f7faff;
}
.action-head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
}
.action-meta {
  color: #5b6b80;
  font-size: 12px;
}
.action-opinion {
  margin: 6px 0 0;
  color: #3d4a5c;
  font-size: 13px;
  line-height: 1.6;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.action-indicators {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 6px;
}
.action-indicator {
  display: inline-flex;
  align-items: baseline;
  gap: 4px;
  padding: 2px 8px;
  border: 1px solid #e3e9f2;
  border-radius: 4px;
  background: #fff;
  color: #5b6b80;
  font-size: 12px;
}
.action-indicator strong {
  color: #172033;
}
.action-indicator em {
  color: #8793a5;
  font-size: 11px;
  font-style: normal;
}
.return-actions {
  margin-top: 12px;
  font-size: 12px;
}
.strong {
  color: #172033;
  font-weight: 600;
}
.muted {
  color: #8793a5;
}
.mono {
  font-variant-numeric: tabular-nums;
}
</style>
src/views/mes/qc/seedQuality/record-processing.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
<script lang="ts" setup>
import RecordPage from './record.vue';
defineOptions({ name: 'MesQcSeedQualityRecordProcessing' });
</script>
<template>
  <RecordPage :initial-qc-line="1" />
</template>
src/views/mes/qc/seedQuality/record-sales.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
<script lang="ts" setup>
import RecordPage from './record.vue';
defineOptions({ name: 'MesQcSeedQualityRecordSales' });
</script>
<template>
  <RecordPage :initial-qc-line="2" />
</template>
src/views/mes/qc/seedQuality/record.vue
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { TablePaginationConfig } from 'ant-design-vue';
import { IconifyIcon } from '@vben/icons';
@@ -10,36 +11,65 @@
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import {
  getSeedQualityBatchPage,
  getSeedQualityPage,
  getSeedQualityIndicators,
  getSeedQualityActions,
  getSeedIndicatorRanges,
  retestSeedQuality,
  retestSeedQualityBatch,
  resolveSeedRetestScope,
  submitSeedRetestScope,
  approveSeedQuality,
  approveSeedQualityBatch,
  retrySeedQualityBpm,
  resolveSeedQuality,
  submitSeedQualityGroup,
  type SeedQualityBatchGroup,
  type SeedQualityRecord,
  type SeedQualityScan,
  type SeedQualityIndicator,
  type SeedQualityAction,
  type SeedRetestScope,
  type EffectiveIndicator,
} from '#/api/mes/qc/seedQuality';
const records = ref<SeedQualityRecord[]>([]);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
interface BatchBucket {
  list: SeedQualityRecord[];
  total: number;
  page: number;
  pageSize: number;
  loading: boolean;
}
const groups = ref<SeedQualityBatchGroup[]>([]);
const groupLoading = ref(false);
const groupTotal = ref(0);
const batchPage = ref(1);
const batchPageSize = ref(10);
const expandedKeys = ref<string[]>([]);
const buckets = ref<Record<string, BatchBucket>>({});
const batchSelections = ref<Record<string, number[]>>({});
const loading = computed(() =>
  groupLoading.value || Object.values(buckets.value).some((bucket) => bucket.loading),
);
/** å›ºå®šè´¨æ£€çº¿ï¼š1-加工部,2-销售部;不传表示全部 */
const props = withDefaults(defineProps<{ initialQcLine?: number }>(), {
  initialQcLine: undefined,
});
const code = ref('');
const batchCode = ref('');
const result = ref<number>();
const workflowStatus = ref<number>();
const qcLine = ref<number | undefined>(props.initialQcLine);
const total = ref(0);
const pageTitle = computed(() => {
  if (props.initialQcLine === 1) return '加工部质检';
  if (props.initialQcLine === 2) return '销售部质检';
  return '质检记录';
});
const statsTotal = ref(0);
const passTotal = ref(0);
const failTotal = ref(0);
@@ -48,14 +78,52 @@
  Math.max(statsTotal.value - passTotal.value - failTotal.value, 0),
);
const { hasAccessByCodes } = useAccess();
const router = useRouter();
function openProcess(processInstanceId?: string) {
  if (!processInstanceId) {
    message.warning('审批流程尚未生成,请联系管理员检查 BPM æµç¨‹é…ç½®');
    return;
  }
  void router.push({ name: 'BpmProcessInstanceDetail', query: { id: processInstanceId } });
}
async function retryProcess(id: number) {
  try {
    await retrySeedQualityBpm(id);
    message.success('已重新发起 BPM å®¡æ‰¹');
    refreshAll();
  } catch (error) {
    message.error(scopeErrorText(error));
  }
}
const canScopeAction = computed(() => [
  'mes:qc-seed-quality:submit', 'mes:qc-seed-quality:retest',
  'mes:qc-seed-quality:process-approve', 'mes:qc-seed-quality:company-approve',
].some((code) => hasAccessByCodes([code])));
const workflowLabels: Record<number, string> = {
  10: '待复检', 20: '待初检审核', 25: '待初检不合格处理', 30: '待复检审核',
  40: '已放行', 50: '初检驳回待重检', 60: '待复检不合格处理', 70: '已报废',
  10: '待复检', 20: '待部门审批', 25: '待部门不合格审批', 30: '待公司质检审批',
  40: '已放行', 50: '初检驳回待重检', 60: '待公司质检不合格处理', 70: '已报废',
};
/** åŒçº¿è´¨æ£€ï¼šåŒä¸€è¢‹ç ç”±åŠ å·¥éƒ¨ã€é”€å”®éƒ¨å„ç”Ÿæˆä¸€æ¡è´¨æ£€è®°å½• */
const qcLineLabels: Record<number, string> = { 0: '未区分', 1: '加工部', 2: '销售部' };
const qcLineColors: Record<number, string> = { 0: 'default', 1: 'blue', 2: 'purple' };
function qcLineLabel(value?: number) {
  return qcLineLabels[value ?? 0] || '未区分';
}
/** ç»é”€å•†é€€è´§è´¨æ£€å¤ç”¨åŒä¸€æµç¨‹çŠ¶æ€ï¼ŒæŒ‰ä¸šåŠ¡ç±»åž‹å±•ç¤ºæ›´å‡†ç¡®çš„èŠ‚ç‚¹åç§° */
function statusLabel(record: SeedQualityRecord) {
  if (record.bizType === 2) {
    if (record.workflowStatus === 10) return '待复检';
    if (record.workflowStatus === 20) return '待销售部领导审批';
    if (record.workflowStatus === 40) return '退货质检通过';
    if (record.workflowStatus === 70) return '退货质检不通过';
  }
  return workflowLabels[record.workflowStatus] || '未知状态';
}
function statusColor(record: SeedQualityRecord) {
  if (record.workflowStatus === 40) return 'green';
  if (record.workflowStatus === 70) return 'red';
  if (record.workflowStatus === 60) return 'orange';
  return 'blue';
}
const actionLabels: Record<string, string> = {
  INITIAL: '初检', INITIAL_RECHECK: '重新初检', RETEST: '复检', PROCESS_APPROVE: '初检审核通过',
  PROCESS_REJECT: '初检审核驳回', COMPANY_APPROVE: '复检审核通过',
@@ -64,6 +132,19 @@
const indicatorLabels: Record<string, string> = {
  purity: '纯度', cleanliness: '净度', germination_rate: '发芽率', moisture: '水分',
};
function indicatorLabel(code: string, name?: string) {
  return name || indicatorLabels[code] || code;
}
function buildIndicators(list?: EffectiveIndicator[]): SeedQualityIndicator[] {
  return (list || []).map((item) => ({
    indicatorCode: item.code,
    indicatorName: item.name || indicatorLabels[item.code] || item.code,
    unit: item.unit,
    value: undefined,
    minValue: Number(item.minValue),
    maxValue: Number(item.maxValue),
  }));
}
function actionColor(action: string) {
  if (action.includes('REJECT')) return 'red';
  if (action.includes('APPROVE')) return 'green';
@@ -119,21 +200,13 @@
  label: warehouse.code ? `${warehouse.name}(${warehouse.code})` : warehouse.name || '',
  value: warehouse.id ?? 0,
})).filter((item) => item.value));
const scopeReviewGroups = computed(() => {
  const scope = retestScope.value;
  if (!scope) return [];
  return [
    { status: 20, stage: 'process' as const, ids: scope.pendingProcessIds || [] },
    { status: 25, stage: 'process' as const, ids: scope.pendingInitialFailIds || [] },
    { status: 30, stage: 'company' as const, ids: scope.pendingCompanyIds || [] },
    { status: 60, stage: 'company' as const, ids: scope.pendingCompanyFailIds || [] },
  ].filter((group) => group.ids.length && hasAccessByCodes([`mes:qc-seed-quality:${group.stage === 'process' ? 'process-approve' : 'company-approve'}`]));
});
const scopeHasReviewPermission = computed(() => {
  const scope = retestScope.value;
  if (!scope) return false;
  return (scope.pendingProcessCount > 0 && hasAccessByCodes(['mes:qc-seed-quality:process-approve']))
    || (scope.pendingCompanyCount > 0 && hasAccessByCodes(['mes:qc-seed-quality:company-approve']));
const scopeReviewGroups = computed(() => [] as Array<{ status: number; stage: 'process' | 'company'; ids: number[] }>);
const scopeHasReviewPermission = computed(() => false);
const retestTargetLabel = computed(() => {
  const record = activeRecord.value;
  if (!record) return '';
  if (record.bizType === 2) return `经销商退货单 ${record.returnSalesCode || record.code}`;
  return `袋码:${record.code} Â· ${qcLineLabel(record.qcLine)}质检线`;
});
const reviewOpen = ref(false);
const reviewBusy = ref(false);
@@ -145,37 +218,77 @@
const selectedIds = ref<number[]>([]);
const selectedRecordMap = ref(new Map<number, SeedQualityRecord>());
const selectedRows = computed(() => selectedIds.value.map((id) => selectedRecordMap.value.get(id)).filter((row): row is SeedQualityRecord => !!row));
const reviewTitle = computed(() => {
  const record = activeRecord.value;
  if (record?.bizType === 2) return record.workflowStatus === 20 ? '销售部领导审批' : '退货复检不合格处理';
  if ([25, 60].includes(record?.workflowStatus ?? 0)) return '不合格处理';
  return reviewStage.value === 'process' ? '初检审核' : '公司质检审批';
});
const reviewGroups = computed(() => [20, 25, 30, 60].map((status) => ({
  status,
  rows: selectedRows.value.filter((row) => row.workflowStatus === status),
  stage: status === 20 || status === 25 ? 'process' as const : 'company' as const,
})).filter((group) => group.rows.length && hasAccessByCodes([`mes:qc-seed-quality:${group.stage === 'process' ? 'process-approve' : 'company-approve'}`])));
})).filter((group) => group.rows.length && group.rows.every((row) => row.qcLine === 0)
  && hasAccessByCodes([`mes:qc-seed-quality:${group.stage === 'process' ? 'process-approve' : 'company-approve'}`])));
const retestGroups = computed(() => {
  if (!hasAccessByCodes(['mes:qc-seed-quality:retest'])) return [];
  const groups = new Map<string, { status: 10 | 50; batchCode: string; rows: SeedQualityRecord[] }>();
  for (const row of selectedRows.value) {
    if (row.workflowStatus !== 10 && row.workflowStatus !== 50) continue;
    if (!row.batchCode) continue;
    const key = `${row.workflowStatus}:${row.batchCode}`;
    const key = `${row.workflowStatus}:${row.batchCode}:${row.qcLine ?? 0}`;
    if (!groups.has(key)) groups.set(key, { status: row.workflowStatus, batchCode: row.batchCode, rows: [] });
    groups.get(key)!.rows.push(row);
  }
  return [...groups.values()];
});
function changeSelection(keys: (string | number)[]) {
  const nextIds = keys.map(Number);
  const nextMap = new Map(selectedRecordMap.value);
  for (const row of records.value) if (nextIds.includes(row.id)) nextMap.set(row.id, row);
  for (const id of nextMap.keys()) if (!nextIds.includes(id)) nextMap.delete(id);
function groupKey(group: SeedQualityBatchGroup) {
  return group.batchCode || '__unbound__';
}
function isExpanded(group: SeedQualityBatchGroup) {
  return expandedKeys.value.includes(groupKey(group));
}
function bucketOf(group: SeedQualityBatchGroup) {
  const key = groupKey(group);
  buckets.value[key] ??= { list: [], total: 0, page: 1, pageSize: 10, loading: false };
  return buckets.value[key]!;
}
function syncSelection() {
  const ids = new Set(Object.values(batchSelections.value).flat());
  const nextMap = new Map<number, SeedQualityRecord>();
  for (const bucket of Object.values(buckets.value)) {
    for (const row of bucket.list) if (ids.has(row.id)) nextMap.set(row.id, row);
  }
  for (const [id, row] of selectedRecordMap.value) {
    if (ids.has(id) && !nextMap.has(id)) nextMap.set(id, row);
  }
  selectedIds.value = [...ids];
  selectedRecordMap.value = nextMap;
  selectedIds.value = nextIds;
}
function changeGroupSelection(group: SeedQualityBatchGroup, keys: (number | string)[]) {
  batchSelections.value = { ...batchSelections.value, [groupKey(group)]: keys.map(Number) };
  syncSelection();
}
function selectionConfig(group: SeedQualityBatchGroup) {
  return {
    selectedRowKeys: batchSelections.value[groupKey(group)] || [],
    preserveSelectedRowKeys: true,
    onChange: (keys: (number | string)[]) => changeGroupSelection(group, keys),
  };
}
function clearSelection() {
  selectedIds.value = [];
  selectedRecordMap.value = new Map();
  batchSelections.value = {};
}
function removeSelection(ids: number[]) {
  const processed = new Set(ids);
  const next: Record<string, number[]> = {};
  for (const [key, value] of Object.entries(batchSelections.value)) {
    const remain = value.filter((id) => !processed.has(id));
    if (remain.length) next[key] = remain;
  }
  batchSelections.value = next;
  selectedIds.value = selectedIds.value.filter((id) => !processed.has(id));
  selectedRecordMap.value = new Map([...selectedRecordMap.value].filter(([id]) => !processed.has(id)));
}
@@ -185,13 +298,12 @@
  activeRecord.value = record;
  try {
    const previous = await getSeedQualityIndicators(record.id);
    const ranges = await getSeedIndicatorRanges().catch(() => []);
    retestIndicators.value = previous.map((item) => {
      const range = ranges.find((row) => row.indicatorCode === item.indicatorCode);
      return { ...item, minValue: range ? Number(range.minValue) : Number(item.minValue), maxValue: range ? Number(range.maxValue) : Number(item.maxValue) };
    });
    if (retestIndicators.value.length !== 4) throw new Error('质检指标不完整');
    retestIndicators.value = retestIndicators.value.map((item) => ({ ...item, value: undefined }));
    retestIndicators.value = previous.map((item) => ({
      ...item,
      indicatorName: indicatorLabel(item.indicatorCode, item.indicatorName),
      value: undefined,
    }));
    if (!retestIndicators.value.length) throw new Error('质检指标记录缺失');
    retestReason.value = '';
    retestOpen.value = true;
  } catch (error) {
@@ -249,12 +361,12 @@
    const scannedCode = scopeCode.value.trim();
    let data: SeedRetestScope;
    try {
      data = await resolveSeedRetestScope(scannedCode, scopeType.value);
      data = await resolveSeedRetestScope(scannedCode, scopeType.value, qcLine.value === 2 ? 2 : 1);
    } catch (error) {
      if (!scopeErrorText(error).startsWith('未找到')) throw error;
      const otherType = scopeType.value === 'PALLET' ? 'BATCH' : 'PALLET';
      try {
        data = await resolveSeedRetestScope(scannedCode, otherType);
        data = await resolveSeedRetestScope(scannedCode, otherType, qcLine.value === 2 ? 2 : 1);
        scopeType.value = otherType;
      } catch (otherError) {
        if (scopeErrorText(otherError).startsWith('未找到')) {
@@ -265,15 +377,9 @@
    }
    await hydrateScopeReviewIds(data, scannedCode);
    retestScope.value = data; scopeStatus.value = data.retestIds.length ? 10 : 50;
    initialScope.value = await resolveSeedQuality(scannedCode, data.scanType);
    const ranges = await getSeedIndicatorRanges().catch(() => []);
    const emptyIndicators = () => Object.entries(indicatorLabels).map(([indicatorCode]) => {
      const range = ranges.find((row) => row.indicatorCode === indicatorCode);
      return { indicatorCode, value: undefined, minValue: range ? Number(range.minValue) : undefined as unknown as number,
        maxValue: range ? Number(range.maxValue) : undefined as unknown as number };
    });
    retestIndicators.value = emptyIndicators();
    initialIndicators.value = emptyIndicators();
    initialScope.value = await resolveSeedQuality(scannedCode, data.scanType, qcLine.value === 2 ? 2 : 1);
    retestIndicators.value = buildIndicators(data.indicators);
    initialIndicators.value = buildIndicators(initialScope.value?.indicators);
    retestReason.value = '';
    initialReason.value = '';
  } catch (error) { scopeError.value = scopeErrorText(error); }
@@ -283,10 +389,10 @@
  const target = initialScope.value;
  if (!target || !target.inbound || !initialSubmitIds.value.length) return;
  if (!qualifiedWarehouseId.value) { scopeError.value = '请选择检验合格后入库的合格库'; return; }
  if (initialIndicators.value.some((item) => item.value === undefined || item.minValue === undefined || item.maxValue === undefined
    || Number(item.value) < 0 || Number(item.value) > 100 || Number(item.minValue) < 0 || Number(item.maxValue) > 100
  if (initialIndicators.value.length === 0 || initialIndicators.value.some((item) => item.value === undefined || item.minValue === undefined || item.maxValue === undefined
    || Number(item.value) < 0 || Number(item.minValue) < 0 || Number(item.maxValue) < 0
    || Number(item.minValue) > Number(item.maxValue))) {
    scopeError.value = '请填写四项指标的实测值和 0–100 èŒƒå›´å†…的合格上下限'; return;
    scopeError.value = '请填写全部质检项的实测值和非负的合格上下限'; return;
  }
  if (!initialPassed.value && !initialReason.value.trim()) { scopeError.value = '初检不合格时请填写原因'; return; }
  scopeError.value = '';
@@ -300,7 +406,8 @@
  scopeBusy.value = true;
  try {
    const submitted = await submitSeedQualityGroup({
      code: target.code, batchCode: target.batchCode, scanType: target.scanType || scopeType.value,
      code: target.code, qcLine: qcLine.value === 2 ? 2 : 1,
      batchCode: target.batchCode, scanType: target.scanType || scopeType.value,
      bagIds: initialSubmitIds.value, qualifiedWarehouseId: qualifiedWarehouseId.value,
      reason: initialReason.value.trim() || undefined,
      indicators: initialIndicators.value.map((item) => ({ code: item.indicatorCode, value: Number(item.value),
@@ -314,15 +421,16 @@
}
async function saveScopeRetest() {
  if (!retestScope.value || !scopeSubmitIds.value.length) return;
  if (retestIndicators.value.some(item => item.value === undefined || item.value === null || item.minValue === undefined || item.maxValue === undefined
    || Number(item.minValue) < 0 || Number(item.maxValue) > 100 || Number(item.minValue) > Number(item.maxValue))) {
    message.warning('请填写四项指标的实测值与本次合格范围'); return;
  if (retestIndicators.value.length === 0 || retestIndicators.value.some(item => item.value === undefined || item.value === null || item.minValue === undefined || item.maxValue === undefined
    || Number(item.minValue) < 0 || Number(item.maxValue) < 0 || Number(item.minValue) > Number(item.maxValue))) {
    message.warning('请填写全部质检项的实测值与本次合格范围'); return;
  }
  const passed = retestIndicators.value.every(item => Number(item.value) >= Number(item.minValue) && Number(item.value) <= Number(item.maxValue));
  if (!passed && !retestReason.value.trim()) { message.warning('不合格请填写原因'); return; }
  scopeBusy.value = true;
  try {
    const result = await submitSeedRetestScope({ code: retestScope.value.code, scanType: retestScope.value.scanType, workflowStatus: scopeStatus.value,
    const result = await submitSeedRetestScope({ code: retestScope.value.code, scanType: retestScope.value.scanType,
      qcLine: qcLine.value === 2 ? 2 : 1, workflowStatus: scopeStatus.value,
      qualityIds: scopeSubmitIds.value, reason: retestReason.value.trim(), indicators: retestIndicators.value.map(item => ({
        code: item.indicatorCode, value: Number(item.value), minValue: Number(item.minValue), maxValue: Number(item.maxValue),
      })) });
@@ -441,31 +549,90 @@
  value === 1 ? '#ecfdf3' : value === 2 ? '#fef2f2' : '#fffbeb';
const formatDateTime = (value?: string) =>
  value ? new Date(value).toLocaleString() : '—';
const formatDate = (value?: string) => (value ? value.slice(0, 10) : '—');
const showGroupTotal = (count: number) => `共 ${count} ä¸ªæ‰¹æ¬¡`;
async function loadRecords() {
  loading.value = true;
function batchQuery() {
  return {
    code: code.value.trim() || undefined,
    batchCode: batchCode.value.trim() || undefined,
    result: result.value,
    workflowStatus: workflowStatus.value,
    qcLine: qcLine.value,
  };
}
async function loadGroups() {
  groupLoading.value = true;
  try {
    const data = await getSeedQualityBatchPage({
      pageNo: batchPage.value,
      pageSize: batchPageSize.value,
      ...batchQuery(),
    });
    groups.value = data.list;
    groupTotal.value = data.total;
    const keys = groups.value.map((group) => groupKey(group));
    expandedKeys.value = expandedKeys.value.filter((key) => keys.includes(key));
    if (!expandedKeys.value.length && groups.value[0] && Object.keys(buckets.value).length === 0) {
      expandedKeys.value = [groupKey(groups.value[0])];
    }
    await Promise.all(groups.value.filter((group) => isExpanded(group)).map((group) => loadBucket(group)));
  } finally {
    groupLoading.value = false;
  }
}
async function loadBucket(group: SeedQualityBatchGroup, page?: number, pageSize?: number) {
  const bucket = bucketOf(group);
  if (page) bucket.page = page;
  if (pageSize) bucket.pageSize = pageSize;
  bucket.loading = 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,
      workflowStatus: workflowStatus.value,
      pageNo: bucket.page,
      pageSize: bucket.pageSize,
      ...batchQuery(),
      batchCode: undefined,
      exactBatchCode: group.batchCode || undefined,
      unbound: !group.batchCode,
    });
    records.value = data.list;
    total.value = data.total;
    bucket.list = data.list;
    bucket.total = data.total;
  } finally {
    loading.value = false;
    bucket.loading = false;
  }
}
async function toggleGroup(group: SeedQualityBatchGroup) {
  const key = groupKey(group);
  if (isExpanded(group)) {
    expandedKeys.value = expandedKeys.value.filter((item) => item !== key);
    return;
  }
  expandedKeys.value = [...expandedKeys.value, key];
  await loadBucket(group);
}
function tablePagination(group: SeedQualityBatchGroup): TablePaginationConfig {
  const bucket = buckets.value[groupKey(group)];
  return {
    current: bucket?.page ?? 1,
    pageSize: bucket?.pageSize ?? 10,
    total: bucket?.total ?? 0,
    showSizeChanger: true,
    showTotal: (count: number) => `共 ${count} æ¡`,
    onChange: (nextPage: number, nextPageSize: number) =>
      void loadBucket(group, nextPage, nextPageSize),
  };
}
async function loadStats() {
  try {
    const [all, pass, fail] = await Promise.all([
      getSeedQualityPage({ pageNo: 1, pageSize: 1 }),
      getSeedQualityPage({ pageNo: 1, pageSize: 1, workflowStatus: 40 }),
      getSeedQualityPage({ pageNo: 1, pageSize: 1, workflowStatus: 70 }),
      getSeedQualityPage({ pageNo: 1, pageSize: 1, qcLine: qcLine.value }),
      getSeedQualityPage({ pageNo: 1, pageSize: 1, qcLine: qcLine.value, workflowStatus: 40 }),
      getSeedQualityPage({ pageNo: 1, pageSize: 1, qcLine: qcLine.value, workflowStatus: 70 }),
    ]);
    statsTotal.value = all.total;
    passTotal.value = pass.total;
@@ -476,9 +643,11 @@
}
function searchRecords() {
  page.value = 1;
  batchPage.value = 1;
  clearSelection();
  void loadRecords();
  buckets.value = {};
  expandedKeys.value = [];
  void loadGroups();
}
function resetFilters() {
@@ -486,21 +655,28 @@
  batchCode.value = '';
  result.value = undefined;
  workflowStatus.value = undefined;
  // å›ºå®šè´¨æ£€çº¿çš„菜单页重置时保留当前质检线
  if (props.initialQcLine === undefined) {
    qcLine.value = undefined;
  }
  searchRecords();
  void loadStats();
}
function refreshAll() {
  void Promise.all([loadRecords(), loadStats()]);
  void Promise.all([loadGroups(), loadStats()]);
}
function refreshAndClearSelection() {
  clearSelection();
  refreshAll();
}
function handleTableChange(pagination: TablePaginationConfig) {
  page.value = pagination.current || 1;
  pageSize.value = pagination.pageSize || 10;
  void loadRecords();
function handleGroupPageChange(nextPage: number, nextSize: number) {
  batchPage.value = nextPage;
  batchPageSize.value = nextSize;
  clearSelection();
  buckets.value = {};
  void loadGroups();
}
onMounted(refreshAll);
@@ -512,8 +688,8 @@
    <header class="page-header">
      <div class="header-copy">
        <div class="eyebrow">MES / è´¨é‡ç®¡ç†</div>
        <h1>质检记录</h1>
        <p>初检审核通过后进行复检,复检审核通过后才放行入库或处置不合格品</p>
        <h1>{{ pageTitle }}</h1>
        <p>每袋保留加工部、销售部两条质检线;初检不合格先复检,双线部门审批完成后进入公司质检审批</p>
      </div>
      <div class="header-badge">
        <span class="badge-pulse" />
@@ -525,7 +701,7 @@
    <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>{{ statsTotal.toLocaleString() }}</strong><small>按袋码计</small></div>
        <div class="stat-main"><span>记录总数</span><strong>{{ statsTotal.toLocaleString() }}</strong><small>按质检记录计</small></div>
      </div>
      <div class="stat-card pass">
        <div class="stat-icon"><IconifyIcon icon="ep:success-filled" /></div>
@@ -560,8 +736,8 @@
      <div class="card-toolbar">
        <div class="card-title">
          <IconifyIcon icon="ep:list" />
          æ˜Žç»†åˆ—表
          <span class="count-chip">{{ total.toLocaleString() }} æ¡</span>
          æŒ‰ç”Ÿäº§æ‰¹æ¬¡æŸ¥çœ‹
          <span class="count-chip">{{ groupTotal.toLocaleString() }} ä¸ªæ‰¹æ¬¡</span>
        </div>
        <div class="filters">
          <a-button v-if="canScopeAction" class="desktop-scope-action" type="primary" @click="openScopeRetest">按托盘 / æ‰¹æ¬¡æ‰¹é‡å¤„理</a-button>
@@ -610,87 +786,146 @@
            v-for="group in retestGroups"
            :key="`retest-${group.status}-${group.batchCode}`"
            @click="openBatchRetest(group)"
          >{{ group.status === 50 ? '重新初检' : '复检' }} Â· {{ group.batchCode }} Â· {{ group.rows.length }} è¢‹</a-button>
          >{{ group.status === 50 ? '重新初检' : '复检' }} Â· {{ group.batchCode }} Â· {{ group.rows.length }} æ¡è®°å½•</a-button>
          <span v-if="!reviewGroups.length && !retestGroups.length" class="muted">所选记录当前没有可批量处理的操作,请查看流程状态或单条操作。</span>
        </div>
      </div>
      <a-table
        :data-source="records"
        :row-selection="{ selectedRowKeys: selectedIds, preserveSelectedRowKeys: true, onChange: changeSelection }"
        :loading="loading"
        :scroll="{ x: 1900 }"
        :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="palletCode" title="托盘码" data-index="palletCode" min-width="190">
          <template #default="{ record }"><span class="mono">{{ record.palletCode || '—' }}</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) }}
      <a-spin :spinning="groupLoading">
        <div v-if="!groups.length && !groupLoading" class="empty-state">
          <IconifyIcon icon="ep:data-board" />
          <p>暂无质检记录</p>
        </div>
        <div
          v-for="group in groups"
          :key="groupKey(group)"
          class="batch-group"
          :class="{ expanded: isExpanded(group) }"
        >
          <div
            class="batch-head"
            role="button"
            tabindex="0"
            @click="toggleGroup(group)"
            @keydown.enter="toggleGroup(group)"
            @keydown.space.prevent="toggleGroup(group)"
          >
            <span class="batch-chevron">
              <IconifyIcon :icon="isExpanded(group) ? 'ep:arrow-down' : 'ep:arrow-right'" />
            </span>
          </template>
        </a-table-column>
        <a-table-column key="workflowStatus" title="流程状态" width="190">
          <template #default="{ record }"><a-tag color="blue">{{ workflowLabels[record.workflowStatus] || '未知状态' }}</a-tag></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-column key="actions" title="操作" width="260" fixed="right">
          <template #default="{ record }">
            <a-space>
              <a-button v-if="[10, 50].includes(record.workflowStatus) && hasAccessByCodes(['mes:qc-seed-quality:retest'])" type="link" @click="openRetest(record)">{{ record.workflowStatus === 50 ? '重新初检' : '复检' }}</a-button>
              <a-button v-if="[20, 25].includes(record.workflowStatus) && hasAccessByCodes(['mes:qc-seed-quality:process-approve'])" type="link" @click="openReview(record, 'process')">{{ record.workflowStatus === 25 ? '不合格处理' : '初检审核' }}</a-button>
              <a-button v-if="[30, 60].includes(record.workflowStatus) && hasAccessByCodes(['mes:qc-seed-quality:company-approve'])" type="link" @click="openReview(record, 'company')">{{ record.workflowStatus === 60 ? '不合格处理' : '复检审核' }}</a-button>
              <a-button v-if="hasAccessByCodes(['mes:qc-seed-quality:query'])" type="link" @click="openHistory(record)">流程记录</a-button>
            </a-space>
          </template>
        </a-table-column>
      </a-table>
            <span class="batch-code">{{ group.batchLabel }}</span>
            <span class="batch-meta">
              <span v-if="group.itemName">品种 {{ group.itemName }}</span>
              <span v-if="group.lineName">{{ group.lineName }}</span>
              <span v-if="group.produceDate">生产 {{ formatDate(group.produceDate) }}</span>
              <span v-if="group.lastInspectedAt">最近检验 {{ formatDateTime(group.lastInspectedAt) }}</span>
            </span>
            <span class="batch-counts">
              <span class="count-pill total">共 {{ group.totalCount }} è¢‹</span>
              <span class="count-pill pass">合格 {{ group.passCount }}</span>
              <span class="count-pill fail">不合格 {{ group.failCount }}</span>
              <span class="count-pill pending">处理中 {{ group.processingCount }}</span>
            </span>
            <span class="batch-flags">
              <a-tag v-if="group.pendingProcessReviewCount" color="orange">待初检审核 {{ group.pendingProcessReviewCount }}</a-tag>
              <a-tag v-if="group.pendingCompanyReviewCount" color="gold">待公司质检审批 {{ group.pendingCompanyReviewCount }}</a-tag>
              <a-tag v-if="group.pendingRetestCount" color="blue">待检验 {{ group.pendingRetestCount }}</a-tag>
              <a-tag v-if="group.releasedCount" color="green">已放行 {{ group.releasedCount }}</a-tag>
              <a-tag v-if="group.scrappedCount" color="red">已报废 {{ group.scrappedCount }}</a-tag>
            </span>
          </div>
          <div v-if="isExpanded(group)" class="batch-body">
            <a-table
              :data-source="buckets[groupKey(group)]?.list || []"
              :loading="buckets[groupKey(group)]?.loading"
              :pagination="tablePagination(group)"
              :row-selection="selectionConfig(group)"
              :scroll="{ x: 1500 }"
              row-key="id"
              size="middle"
            >
              <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="palletCode" title="托盘码" data-index="palletCode" min-width="190">
                <template #default="{ record }"><span class="mono">{{ record.palletCode || '—' }}</span></template>
              </a-table-column>
              <a-table-column key="qcLine" title="质检线" width="150">
                <template #default="{ record }">
                  <a-space>
                    <a-tag :color="qcLineColors[record.qcLine ?? 0]">{{ qcLineLabel(record.qcLine) }}</a-tag>
                    <a-tooltip v-if="record.bizType === 2" :title="`经销商退货单 ${record.returnSalesCode || record.code}`">
                      <a-tag color="magenta">退货</a-tag>
                    </a-tooltip>
                  </a-space>
                </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="workflowStatus" title="流程状态" width="190">
                <template #default="{ record }"><a-tag :color="statusColor(record)">{{ statusLabel(record) }}</a-tag></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-column key="actions" title="操作" width="260" fixed="right">
                <template #default="{ record }">
                  <a-space>
                    <a-button v-if="[10, 50].includes(record.workflowStatus) && hasAccessByCodes(['mes:qc-seed-quality:retest'])" type="link" @click="openRetest(record)">{{ record.workflowStatus === 50 ? '重新初检' : '复检' }}</a-button>
                    <a-button v-if="[20, 25].includes(record.workflowStatus)" type="link" @click="openProcess(record.processInstanceId)">查看部门审批</a-button>
                    <a-button v-if="[30, 60].includes(record.workflowStatus)" type="link" @click="openProcess(record.companyProcessInstanceId)">查看公司审批</a-button>
                    <a-button v-if="[20, 25, 30, 60].includes(record.workflowStatus) && !(record.workflowStatus === 20 || record.workflowStatus === 25 ? record.processInstanceId : record.companyProcessInstanceId) && hasAccessByCodes(['mes:qc-seed-quality:process-approve'])" type="link" @click="retryProcess(record.id)">重新发起审批</a-button>
                    <a-button v-if="hasAccessByCodes(['mes:qc-seed-quality:query'])" type="link" @click="openHistory(record)">流程记录</a-button>
                  </a-space>
                </template>
              </a-table-column>
            </a-table>
          </div>
        </div>
      </a-spin>
      <div v-if="groupTotal > 0" class="group-pagination">
        <a-pagination
          :current="batchPage"
          :page-size="batchPageSize"
          :total="groupTotal"
          show-size-changer
          :page-size-options="['10', '20', '50']"
          :show-total="showGroupTotal"
          @change="handleGroupPageChange"
        />
      </div>
    </section>
    <a-modal v-model:open="retestOpen" wrap-class-name="seed-quality-modal" :title="activeRecord?.workflowStatus === 50 ? '重新初检' : '种子质检复检'" :confirm-loading="retestBusy" :ok-text="activeRecord?.workflowStatus === 50 ? '提交初检' : '提交复检'" @ok="saveRetest">
      <p class="muted">{{ retestBatchIds.length ? `本次处理 ${retestBatchIds.length} è¢‹ï¼ŒåŒä¸€ç»„指标与结论会应用于全部所选袋码。` : `袋码:${activeRecord?.code}。` }}请录入本次实测值并核对合格范围。</p>
    <a-modal v-model:open="retestOpen" wrap-class-name="seed-quality-modal" :title="activeRecord?.workflowStatus === 50 ? '重新初检' : activeRecord?.bizType === 2 ? '经销商退货复检' : '种子质检复检'" :confirm-loading="retestBusy" :ok-text="activeRecord?.workflowStatus === 50 ? '提交初检' : '提交复检'" @ok="saveRetest">
      <p class="muted">{{ retestBatchIds.length ? `本次处理 ${retestBatchIds.length} æ¡è®°å½•,同一组指标与结论会应用于全部所选记录。` : `${retestTargetLabel}。` }}请录入本次实测值并核对合格范围。</p>
      <div v-for="item in retestIndicators" :key="item.indicatorCode" class="retest-row">
        <strong>{{ indicatorLabels[item.indicatorCode] || item.indicatorCode }}</strong>
        <strong>{{ item.indicatorName || indicatorLabel(item.indicatorCode) }}</strong>
        <a-input-number v-model:value="item.value" :min="0" :precision="3" placeholder="本次实测值" />
        <a-input-number v-model:value="item.minValue" :min="0" :max="100" :precision="3" placeholder="下限" />
        <a-input-number v-model:value="item.maxValue" :min="0" :max="100" :precision="3" placeholder="上限" />
        <a-input-number v-model:value="item.minValue" :min="0" :precision="3" placeholder="下限" />
        <a-input-number v-model:value="item.maxValue" :min="0" :precision="3" placeholder="上限" />
      </div>
      <a-textarea v-model:value="retestReason" :rows="3" :maxlength="500" placeholder="本次检验不合格时填写原因" />
    </a-modal>
@@ -707,16 +942,16 @@
          <template v-if="initialIds.length">
            <a-alert v-if="!initialScope?.inbound" type="warning" show-icon message="该范围的有效袋码尚未全部入库,暂不能提交初检" />
            <template v-else-if="hasAccessByCodes(['mes:qc-seed-quality:submit'])">
              <p class="muted">四项指标和结论会应用于本次 {{ initialSubmitIds.length }} è¢‹ï¼›è¶…过 500 è¢‹æ—¶ç³»ç»Ÿä¼šåˆ†æ‰¹å¤„理并自动加载剩余记录。</p>
              <p class="muted">全部质检项和结论会应用于本次 {{ initialSubmitIds.length }} è¢‹ï¼›è¶…过 500 è¢‹æ—¶ç³»ç»Ÿä¼šåˆ†æ‰¹å¤„理并自动加载剩余记录。</p>
              <a-form-item label="检验合格后入库的合格库" required>
                <a-select v-model:value="qualifiedWarehouseId" :options="qualifiedWarehouseOptions" placeholder="请选择合格库" style="width: 100%" />
              </a-form-item>
              <a-alert v-if="!qualifiedWarehouseOptions.length" type="warning" show-icon message="暂无可用合格库,请联系管理员配置" />
              <div v-for="item in initialIndicators" :key="`initial-${item.indicatorCode}`" class="retest-row">
                <strong>{{ indicatorLabels[item.indicatorCode] }}</strong>
                <a-input-number v-model:value="item.value" :min="0" :max="100" :precision="3" placeholder="实测值" />
                <a-input-number v-model:value="item.minValue" :min="0" :max="100" :precision="3" placeholder="下限" />
                <a-input-number v-model:value="item.maxValue" :min="0" :max="100" :precision="3" placeholder="上限" />
                <strong>{{ item.indicatorName || indicatorLabels[item.indicatorCode] }}</strong>
                <a-input-number v-model:value="item.value" :min="0" :precision="3" placeholder="实测值" />
                <a-input-number v-model:value="item.minValue" :min="0" :precision="3" placeholder="下限" />
                <a-input-number v-model:value="item.maxValue" :min="0" :precision="3" placeholder="上限" />
              </div>
              <a-textarea v-model:value="initialReason" :rows="2" :maxlength="500" :placeholder="initialPassed ? '合格结果无需填写原因' : '初检不合格时填写原因'" />
              <div class="scope-actions"><a-button type="primary" :loading="scopeBusy" @click="saveScopeInitial">提交本次 {{ initialSubmitIds.length }} è¢‹åˆæ£€</a-button></div>
@@ -745,10 +980,10 @@
            </a-radio-group>
            <p class="muted">同一组指标将应用于本次 {{ scopeSubmitIds.length }} è¢‹ï¼›è¶…过 500 è¢‹æ—¶å®ŒæˆåŽç»§ç»­å¤„理剩余记录。模板范围可以修改。</p>
            <div v-for="item in retestIndicators" :key="item.indicatorCode" class="retest-row">
              <strong>{{ indicatorLabels[item.indicatorCode] }}</strong>
              <a-input-number v-model:value="item.value" :min="0" :max="100" :precision="3" placeholder="实测值" />
              <a-input-number v-model:value="item.minValue" :min="0" :max="100" :precision="3" placeholder="下限" />
              <a-input-number v-model:value="item.maxValue" :min="0" :max="100" :precision="3" placeholder="上限" />
              <strong>{{ item.indicatorName || indicatorLabels[item.indicatorCode] }}</strong>
              <a-input-number v-model:value="item.value" :min="0" :precision="3" placeholder="实测值" />
              <a-input-number v-model:value="item.minValue" :min="0" :precision="3" placeholder="下限" />
              <a-input-number v-model:value="item.maxValue" :min="0" :precision="3" placeholder="上限" />
            </div>
            <a-textarea v-model:value="retestReason" :rows="2" :maxlength="500" placeholder="不合格时填写原因" />
            <div class="scope-actions"><a-button type="primary" :loading="scopeBusy" @click="saveScopeRetest">提交本次 {{ scopeSubmitIds.length }} è¢‹æ£€éªŒ</a-button></div>
@@ -758,13 +993,20 @@
        <a-alert v-if="!initialIds.length && !scopePendingCount && !retestScope.retestIds.length && !retestScope.initialRecheckIds.length" type="success" show-icon message="当前范围没有待处理的质检记录" />
      </template>
    </a-modal>
    <a-modal v-model:open="reviewOpen" wrap-class-name="seed-quality-modal" :title="[25, 60].includes(activeRecord?.workflowStatus ?? 0) ? '不合格处理' : reviewStage === 'process' ? '初检审核' : '复检审核'" :confirm-loading="reviewBusy" :ok-text="reviewApproved ? '确认处理' : '确认驳回'" @ok="saveReview">
      <p>{{ reviewFromScope || reviewIds.length > 1 ? `共 ${reviewIds.length} æ¡è®°å½•` : activeRecord?.code }} Â· {{ workflowLabels[activeRecord?.workflowStatus ?? 0] }}</p>
    <a-modal v-model:open="reviewOpen" wrap-class-name="seed-quality-modal" :title="reviewTitle" :confirm-loading="reviewBusy" :ok-text="reviewApproved ? '确认处理' : '确认驳回'" @ok="saveReview">
      <p>{{ reviewFromScope || reviewIds.length > 1 ? `共 ${reviewIds.length} æ¡è®°å½•` : activeRecord?.code }} Â· {{ activeRecord ? statusLabel(activeRecord) : '' }}</p>
      <a-radio-group v-model:value="reviewApproved" class="review-choice">
        <a-radio :value="true">{{ reviewStage === 'process' ? activeRecord?.workflowStatus === 25 ? '确认不合格,安排复检' : '通过,进入复检' : activeRecord?.workflowStatus === 30 ? '通过,放行入库' : '确认不合格,报废处置' }}</a-radio>
        <a-radio :value="false">{{ reviewStage === 'process' ? '驳回,重新初检' : '驳回,重新复检' }}</a-radio>
        <a-radio :value="true">{{ activeRecord?.bizType === 2 ? '确认合格,进入复检' : reviewStage === 'process' ? activeRecord?.workflowStatus === 25 ? '确认不合格,安排复检' : '通过,进入复检' : activeRecord?.workflowStatus === 30 ? '通过,放行入库' : '确认不合格,报废处置' }}</a-radio>
        <a-radio :value="false">{{ activeRecord?.bizType === 2 ? '确认不合格,不予退货并作废' : reviewStage === 'process' ? '驳回,重新初检' : '驳回,两条线一起退回待复检' }}</a-radio>
      </a-radio-group>
      <a-alert v-if="reviewIds.length > 1" type="info" show-icon :message="reviewApproved ? reviewStage === 'company' ? activeRecord?.workflowStatus === 60 ? `确认后 ${reviewIds.length} è¢‹å°†æŠ¥åºŸ` : `确认后 ${reviewIds.length} è¢‹å°†æ”¾è¡Œå…¥åº“` : `确认后 ${reviewIds.length} è¢‹å°†è¿›å…¥å¤æ£€` : `驳回后 ${reviewIds.length} è¢‹å°†è¿”回检验`" />
      <a-alert
        v-if="reviewStage === 'company' && activeRecord?.bizType !== 2"
        type="info" show-icon message="公司质检审批按袋处理"
        description="确认后该袋加工部、销售部两条质检线同时更新:两条线复检都合格才放行入库,任一条不合格则整袋报废;驳回时两条线一起退回待复检。"
      />
      <a-alert v-else-if="activeRecord?.bizType === 2" type="info" show-icon message="经销商退货质检"
        description="审批通过后退货单进入待执行;审批不通过则退货单自动取消并留存货检记录。" />
      <a-alert v-if="reviewIds.length > 1" type="info" show-icon :message="reviewApproved ? reviewStage === 'company' ? activeRecord?.workflowStatus === 60 ? `确认后 ${reviewIds.length} æ¡è®°å½•(按袋合并)将报废` : `确认后 ${reviewIds.length} æ¡è®°å½•(按袋合并)将放行入库` : `确认后 ${reviewIds.length} æ¡è®°å½•将进入复检` : `驳回后 ${reviewIds.length} æ¡è®°å½•将返回检验`" />
      <a-textarea v-model:value="reviewOpinion" :rows="3" :maxlength="500" :placeholder="reviewApproved ? '审批意见(选填)' : '请填写驳回意见'" />
    </a-modal>
    <a-modal v-model:open="historyOpen" wrap-class-name="seed-quality-modal seed-quality-history-modal"
@@ -834,6 +1076,24 @@
.filters .ant-select { width: 128px; }
.filters .ant-btn { display: inline-flex; align-items: center; gap: 5px; }
.filters :deep(.ant-btn .iconify) { font-size: 14px; }
/* ===== æŒ‰ç”Ÿäº§æ‰¹æ¬¡æŠ˜å  ===== */
.batch-group { margin-bottom: 12px; border: 1px solid #e8edf5; border-radius: 10px; overflow: hidden; background: #fff; transition: box-shadow .2s ease; }
.batch-group.expanded { box-shadow: 0 4px 16px #243b5314; }
.batch-head { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px; padding: 13px 16px; border-left: 3px solid #1677ff; background: linear-gradient(90deg, #f8fbff 0%, #fff 70%); cursor: pointer; }
.batch-head:hover { background: #f5f9ff; }
.batch-chevron { display: inline-flex; color: #1677ff; font-size: 16px; }
.batch-code { color: #172033; font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; }
.batch-meta { display: inline-flex; flex-wrap: wrap; gap: 4px 12px; color: #718096; font-size: 12px; }
.batch-counts { display: inline-flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }
.count-pill { padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; line-height: 20px; font-variant-numeric: tabular-nums; }
.count-pill.total { color: #334155; background: #eef2f7; }
.count-pill.pass { color: #16a34a; background: #ecfdf3; }
.count-pill.fail { color: #dc2626; background: #fef2f2; }
.count-pill.pending { color: #d97706; background: #fffbeb; }
.batch-flags { display: inline-flex; flex-wrap: wrap; gap: 4px; }
.batch-flags :deep(.ant-tag) { margin: 0; }
.batch-body { padding: 0 12px 12px; border-top: 1px dashed #eef1f6; background: #fff; }
.group-pagination { display: flex; justify-content: flex-end; padding: 14px 0 2px; }
.selection-bar { padding: 12px 0; border-bottom: 1px solid #eef1f6; }
.selection-summary, .selection-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.selection-summary { margin-bottom: 8px; color: #59677b; }
@@ -891,6 +1151,10 @@
  .mobile-batch-copy p { margin: 0; color: #718096; font-size: 13px; line-height: 1.55; }
  .mobile-batch-icon { display: flex; flex: 0 0 38px; width: 38px; height: 38px; align-items: center; justify-content: center; border-radius: 10px; color: #1677ff; background: #eaf3ff; font-size: 20px; }
  .desktop-scope-action { display: none !important; }
  .batch-head { align-items: flex-start; padding: 12px; }
  .batch-counts { width: 100%; margin-left: 0; }
  .batch-body { padding: 0 4px 8px; }
  .group-pagination { justify-content: center; }
  .record-card { padding: 4px 12px 12px; }
  .card-toolbar { align-items: flex-start; flex-direction: column; }
  .filters { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); }
src/views/mes/qc/seedRange/index.vue
@@ -1,91 +1,488 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { getSeedIndicatorRanges, saveSeedIndicatorRanges } from '#/api/mes/qc/seedQuality';
import { IconifyIcon } from '@vben/icons';
import { message } from 'ant-design-vue';
const definitions = [
  { indicatorCode: 'purity', name: '纯度' },
  { indicatorCode: 'cleanliness', name: '净度' },
  { indicatorCode: 'germination_rate', name: '发芽率' },
  { indicatorCode: 'moisture', name: '水分' },
];
const ranges = ref(definitions.map((item) => ({ ...item, minValue: undefined as number | undefined, maxValue: undefined as number | undefined })));
const loading = ref(false);
const saving = ref(false);
import type { SeedIndicatorRange } from '#/api/mes/qc/seedQuality';
import {
  copyDefaultSeedIndicatorRanges,
  createSeedIndicatorRange,
  deleteSeedIndicatorRange,
  getSeedIndicatorRanges,
  updateSeedIndicatorRange,
} from '#/api/mes/qc/seedQuality';
import { getItemPage } from '#/api/mdm/item';
interface ProductOption {
  id: number;
  code: string;
  name: string;
}
const { hasAccessByCodes } = useAccess();
const complete = computed(() => ranges.value.every((item) =>
  item.minValue !== undefined && item.maxValue !== undefined && item.minValue <= item.maxValue));
const canEdit = computed(() => hasAccessByCodes(['mes:qc-seed-range:update']));
async function load() {
const products = ref<ProductOption[]>([]);
const productLoading = ref(false);
const selectedItemId = ref<number>();
const selectedProduct = computed(() => products.value.find((item) => item.id === selectedItemId.value));
const items = ref<SeedIndicatorRange[]>([]);
const loading = ref(false);
const copying = ref(false);
const togglingId = ref<number>();
const enabledCount = computed(() => items.value.filter((item) => item.status === 0).length);
const disabledCount = computed(() => items.value.length - enabledCount.value);
const columns = [
  { title: '质检项', dataIndex: 'indicatorName', key: 'indicatorName', width: 200 },
  { title: '编码', dataIndex: 'indicatorCode', key: 'indicatorCode', width: 180 },
  { title: '合格范围', key: 'range', width: 180 },
  { title: '排序', dataIndex: 'sort', key: 'sort', width: 90, align: 'center' as const },
  { title: '状态', key: 'status', width: 120, align: 'center' as const },
  { title: '操作', key: 'action', width: 150, align: 'center' as const, fixed: 'right' as const },
];
const modalOpen = ref(false);
const saving = ref(false);
const editingId = ref<number>();
const form = reactive({
  indicatorCode: '',
  indicatorName: '',
  unit: '%',
  minValue: undefined as number | undefined,
  maxValue: undefined as number | undefined,
  sort: 0,
  status: 0,
});
function formatNumber(value?: number | string) {
  const num = Number(value);
  return Number.isFinite(num) ? String(num) : '—';
}
function rangeText(row: SeedIndicatorRange) {
  return `${formatNumber(row.minValue)} ~ ${formatNumber(row.maxValue)}${row.unit ? ` ${row.unit}` : ''}`;
}
async function loadProducts(search?: string) {
  productLoading.value = true;
  try {
    const page = await getItemPage({
      pageNo: 1,
      pageSize: 100,
      status: 0,
      name: search?.trim() || undefined,
    });
    const list = (page.list || []).map((item) => ({
      id: Number(item.id),
      code: item.code,
      name: item.name,
    }));
    if (selectedProduct.value && !list.some((item) => item.id === selectedProduct.value?.id)) {
      list.unshift(selectedProduct.value);
    }
    products.value = list;
  } catch (error) {
    message.error((error as Error).message || '读取产品列表失败');
  } finally {
    productLoading.value = false;
  }
}
async function loadItems() {
  if (!selectedItemId.value) {
    items.value = [];
    return;
  }
  loading.value = true;
  try {
    const data = await getSeedIndicatorRanges();
    ranges.value = definitions.map((item) => {
      const saved = data.find((row) => row.indicatorCode === item.indicatorCode);
      return { ...item, minValue: saved ? Number(saved.minValue) : undefined,
        maxValue: saved ? Number(saved.maxValue) : undefined };
    });
    items.value = await getSeedIndicatorRanges({ itemId: selectedItemId.value });
  } catch (error) {
    message.error((error as Error).message || '读取合格范围失败');
    items.value = [];
    message.error((error as Error).message || '读取质检项失败');
  } finally {
    loading.value = false;
  }
}
async function save() {
  if (!complete.value || ranges.value.some((item) => item.minValue! < 0 || item.maxValue! > 100)) {
    message.warning('请为四项指标配置 0–100% çš„æœ‰æ•ˆä¸Šä¸‹é™');
async function onProductChange(value: number) {
  selectedItemId.value = value;
  await loadItems();
}
function resetForm() {
  form.indicatorCode = '';
  form.indicatorName = '';
  form.unit = '%';
  form.minValue = undefined;
  form.maxValue = undefined;
  form.sort = 0;
  form.status = 0;
}
function openCreate() {
  if (!selectedItemId.value) {
    message.warning('请先选择产品');
    return;
  }
  editingId.value = undefined;
  resetForm();
  form.sort = items.value.length * 10 + 10;
  modalOpen.value = true;
}
function openEdit(row: SeedIndicatorRange) {
  editingId.value = row.id;
  form.indicatorCode = row.indicatorCode;
  form.indicatorName = row.indicatorName;
  form.unit = row.unit || '%';
  form.minValue = Number(row.minValue);
  form.maxValue = Number(row.maxValue);
  form.sort = row.sort ?? 0;
  form.status = row.status ?? 0;
  modalOpen.value = true;
}
function validateForm() {
  if (!form.indicatorName.trim()) {
    message.warning('请填写质检项名称');
    return false;
  }
  if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(form.indicatorCode.trim())) {
    message.warning('编码必须以字母开头,仅可包含字母、数字和下划线');
    return false;
  }
  if (form.minValue === undefined || form.maxValue === undefined) {
    message.warning('请填写合格上下限');
    return false;
  }
  if (Number(form.minValue) < 0 || Number(form.maxValue) < 0) {
    message.warning('合格上下限不能为负数');
    return false;
  }
  if (Number(form.minValue) > Number(form.maxValue)) {
    message.warning('合格下限不能大于上限');
    return false;
  }
  return true;
}
async function submitForm() {
  if (!selectedItemId.value || !validateForm()) return;
  saving.value = true;
  const payload = {
    itemId: selectedItemId.value,
    indicatorCode: form.indicatorCode.trim(),
    indicatorName: form.indicatorName.trim(),
    unit: form.unit?.trim() || '%',
    minValue: Number(form.minValue),
    maxValue: Number(form.maxValue),
    sort: form.sort ?? 0,
    status: form.status ?? 0,
  };
  try {
    await saveSeedIndicatorRanges(ranges.value.map((item) => ({
      indicatorCode: item.indicatorCode,
      minValue: item.minValue!, maxValue: item.maxValue!,
    })));
    message.success('默认合格范围已保存,检验时仍可按实际情况调整');
    await load();
    if (editingId.value) {
      await updateSeedIndicatorRange(editingId.value, payload);
      message.success('质检项已更新');
    } else {
      await createSeedIndicatorRange(payload);
      message.success('质检项已新增');
    }
    modalOpen.value = false;
    await loadItems();
  } catch (error) {
    message.error((error as Error).message || '保存合格范围失败');
    message.error((error as Error).message || '保存质检项失败');
  } finally {
    saving.value = false;
  }
}
onMounted(load);
async function removeItem(row: SeedIndicatorRange) {
  try {
    await deleteSeedIndicatorRange(row.id);
    message.success('质检项已删除');
    await loadItems();
  } catch (error) {
    message.error((error as Error).message || '删除质检项失败');
  }
}
async function toggleStatus(row: SeedIndicatorRange) {
  if (!canEdit.value || !selectedItemId.value) return;
  const next = row.status === 0 ? 1 : 0;
  togglingId.value = row.id;
  try {
    await updateSeedIndicatorRange(row.id, {
      itemId: selectedItemId.value,
      indicatorCode: row.indicatorCode,
      indicatorName: row.indicatorName,
      unit: row.unit || '%',
      minValue: Number(row.minValue),
      maxValue: Number(row.maxValue),
      sort: row.sort ?? 0,
      status: next,
    });
    row.status = next;
    message.success(next === 0 ? '已启用' : '已停用');
  } catch (error) {
    message.error((error as Error).message || '更新状态失败');
  } finally {
    togglingId.value = undefined;
  }
}
async function copyDefaults() {
  if (!selectedItemId.value) return;
  copying.value = true;
  try {
    const count = await copyDefaultSeedIndicatorRanges(selectedItemId.value);
    message.success(count ? `已复制 ${count} ä¸ªé€šç”¨é»˜è®¤é¡¹` : '没有可复制的通用默认项');
    await loadItems();
  } catch (error) {
    message.error((error as Error).message || '复制默认项失败');
  } finally {
    copying.value = false;
  }
}
onMounted(async () => {
  await loadProducts();
  if (products.value.length) {
    selectedItemId.value = products.value[0]!.id;
    await loadItems();
  }
});
</script>
<template>
  <div class="range-page">
    <header class="page-header">
      <div><div class="eyebrow">MES / è´¨é‡ç®¡ç†</div><h1>质检项合格范围配置</h1>
        <p>为初检和复检提供四项指标的默认范围。</p></div>
    </header>
    <a-alert type="info" show-icon message="这里配置的是填写模板" description="检验员可以修改本次合格上下限;实际提交的上下限和实测值一同保存,历史记录不受模板修改影响。" />
    <a-card class="range-card" :loading="loading" :bordered="false">
      <div class="grid-head"><span>质检项</span><span>合格下限</span><span>合格上限</span><span>单位</span></div>
      <div v-for="item in ranges" :key="item.indicatorCode" class="range-row">
        <strong>{{ item.name }}</strong>
        <a-input-number v-model:value="item.minValue" :min="0" :max="100" :precision="3" :disabled="!hasAccessByCodes(['mes:qc-seed-range:update'])" placeholder="下限" />
        <a-input-number v-model:value="item.maxValue" :min="0" :max="100" :precision="3" :disabled="!hasAccessByCodes(['mes:qc-seed-range:update'])" placeholder="上限" />
        <span>%</span>
      <div class="header-main">
        <div class="eyebrow">MES / è´¨é‡ç®¡ç†</div>
        <h1>质检项合格范围配置</h1>
        <p>按产品维护质检项与默认合格范围,初检与复检按该产品的质检项录入。</p>
      </div>
      <div class="actions">
        <a-button @click="load">刷新</a-button>
        <a-button v-if="hasAccessByCodes(['mes:qc-seed-range:update'])" type="primary" :loading="saving" :disabled="!complete" @click="save">保存合格范围</a-button>
      <div class="header-badge"><IconifyIcon icon="ep:box" /> äº§å“ç»´åº¦é…ç½®</div>
    </header>
    <a-alert
      type="info"
      show-icon
      message="配置规则"
      description="每个质检项必须绑定产品。检验员仍可修改本次合格上下限,实际提交的范围和实测值会随记录保存,历史记录不受模板修改影响。"
    />
    <a-card class="range-card" :bordered="false">
      <div class="toolbar">
        <div class="toolbar-select">
          <span class="toolbar-label">选择产品</span>
          <a-select
            :value="selectedItemId"
            class="product-select"
            show-search
            :filter-option="false"
            :loading="productLoading"
            placeholder="搜索产品编码或名称"
            :options="products.map((item) => ({ label: `${item.name}(${item.code})`, value: item.id }))"
            @change="onProductChange"
            @search="loadProducts"
          />
        </div>
        <div class="toolbar-actions">
          <a-button :loading="loading" @click="loadItems">刷新</a-button>
          <a-button v-if="canEdit" :loading="copying" :disabled="!selectedItemId" @click="copyDefaults">
            å¤åˆ¶é€šç”¨é»˜è®¤é¡¹
          </a-button>
          <a-button v-if="canEdit" type="primary" :disabled="!selectedItemId" @click="openCreate">
            <IconifyIcon icon="ep:plus" /> æ–°å¢žè´¨æ£€é¡¹
          </a-button>
        </div>
      </div>
      <div v-if="selectedProduct" class="product-summary">
        <div class="product-identity">
          <span class="product-name">{{ selectedProduct.name }}</span>
          <span class="product-code">{{ selectedProduct.code }}</span>
        </div>
        <div class="product-stats">
          <span class="stat"><b>{{ items.length }}</b> ä¸ªè´¨æ£€é¡¹</span>
          <span class="stat stat-success"><b>{{ enabledCount }}</b> å¯ç”¨</span>
          <span class="stat stat-muted"><b>{{ disabledCount }}</b> åœç”¨</span>
        </div>
      </div>
      <div v-if="!selectedItemId" class="placeholder-box">
        <IconifyIcon icon="ep:search" class="placeholder-icon" />
        <p>请选择产品后维护质检项</p>
      </div>
      <a-table
        v-else
        :columns="columns"
        :data-source="items"
        :loading="loading"
        :pagination="false"
        :scroll="{ x: 920 }"
        row-key="id"
        size="middle"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.key === 'range'">
            <span class="range-text">{{ rangeText(record) }}</span>
          </template>
          <template v-else-if="column.key === 'status'">
            <a-switch
              v-if="canEdit"
              :checked="record.status === 0"
              :loading="togglingId === record.id"
              size="small"
              @change="toggleStatus(record)"
            />
            <a-tag v-else :color="record.status === 0 ? 'success' : 'default'">
              {{ record.status === 0 ? '启用' : '停用' }}
            </a-tag>
          </template>
          <template v-else-if="column.key === 'action'">
            <div v-if="canEdit" class="row-actions">
              <a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
              <a-popconfirm
                title="确认删除该质检项?"
                description="历史质检记录不受影响。"
                ok-text="删除"
                cancel-text="取消"
                @confirm="removeItem(record)"
              >
                <a-button type="link" size="small" danger>删除</a-button>
              </a-popconfirm>
            </div>
            <span v-else class="muted">—</span>
          </template>
        </template>
      </a-table>
      <div v-if="selectedItemId && !loading && !items.length" class="empty-state">
        <IconifyIcon icon="ep:document-add" class="empty-icon" />
        <p class="empty-title">该产品尚未配置质检项</p>
        <p class="empty-desc">未配置时质检将使用通用默认项,可复制后按需调整。</p>
        <div v-if="canEdit" class="empty-actions">
          <a-button type="primary" @click="openCreate">新增质检项</a-button>
          <a-button :loading="copying" @click="copyDefaults">复制通用默认项</a-button>
        </div>
      </div>
    </a-card>
    <a-modal
      v-model:open="modalOpen"
      :title="editingId ? '编辑质检项' : '新增质检项'"
      :confirm-loading="saving"
      ok-text="保存"
      cancel-text="取消"
      width="560px"
      @ok="submitForm"
    >
      <div class="modal-product">
        <span class="modal-product-label">绑定产品</span>
        <span class="modal-product-value">{{ selectedProduct ? `${selectedProduct.name}(${selectedProduct.code})` : '—' }}</span>
      </div>
      <div class="form-section">
        <div class="form-section-title">基本信息</div>
        <div class="form-grid">
          <a-form-item label="质检项名称" required>
            <a-input v-model:value="form.indicatorName" :maxlength="64" placeholder="如:纯度" />
          </a-form-item>
          <a-form-item label="质检项编码" required>
            <a-input
              v-model:value="form.indicatorCode"
              :disabled="!!editingId"
              :maxlength="64"
              placeholder="如:purity"
            />
          </a-form-item>
          <a-form-item label="单位">
            <a-input v-model:value="form.unit" :maxlength="16" placeholder="如:%、粒/公斤" />
          </a-form-item>
          <a-form-item label="状态">
            <a-select v-model:value="form.status">
              <a-select-option :value="0">启用</a-select-option>
              <a-select-option :value="1">停用</a-select-option>
            </a-select>
          </a-form-item>
        </div>
      </div>
      <div class="form-section">
        <div class="form-section-title">合格范围</div>
        <div class="range-row">
          <a-form-item label="合格下限" required>
            <a-input-number v-model:value="form.minValue" class="full-width" :min="0" :precision="3" placeholder="下限" />
          </a-form-item>
          <span class="range-separator">~</span>
          <a-form-item label="合格上限" required>
            <a-input-number v-model:value="form.maxValue" class="full-width" :min="0" :precision="3" placeholder="上限" />
          </a-form-item>
          <a-form-item label="排序">
            <a-input-number v-model:value="form.sort" class="full-width" :min="0" :precision="0" />
          </a-form-item>
        </div>
      </div>
    </a-modal>
  </div>
</template>
<style scoped>
.range-page { padding: 24px; min-height: 100%; background: #f4f7fb; }
.page-header { margin-bottom: 20px; }.eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
h1 { margin: 6px 0; color: #172033; font-size: 28px; }.page-header p { margin: 0; color: #718096; }
.range-card { margin-top: 16px; border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }
.grid-head, .range-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr) minmax(120px, 1fr) 70px; align-items: center; gap: 16px; padding: 12px 16px; }
.grid-head { color: #718096; background: #f7f9fc; font-weight: 600; }.range-row { border-bottom: 1px solid #e8edf5; }.range-row :deep(.ant-input-number) { width: 100%; }
.actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
@media (max-width: 640px) { .range-page { padding: 14px; }.grid-head { display: none; }.range-row { grid-template-columns: 1fr 1fr; }.range-row strong { grid-column: 1 / -1; } }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 20px; }
.header-main { min-width: 0; }
.eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
h1 { margin: 6px 0; color: #172033; font-size: 28px; }
.page-header p { margin: 0; color: #718096; }
.header-badge { display: inline-flex; flex: none; 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; }
.range-card { margin-top: 16px; border-radius: 12px; box-shadow: 0 3px 12px #243b5310; }
.toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding-bottom: 14px; }
.toolbar-select { display: flex; align-items: center; gap: 10px; }
.toolbar-label { color: #59677b; font-size: 13px; font-weight: 600; white-space: nowrap; }
.product-select { width: min(360px, 100%); }
.toolbar-actions { display: flex; align-items: center; gap: 10px; }
.product-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; padding: 12px 16px; border: 1px solid #d6e8ff; border-radius: 10px; background: linear-gradient(90deg, #f7fbff, #fbfdff); }
.product-identity { display: flex; align-items: baseline; gap: 10px; }
.product-name { font-size: 16px; font-weight: 700; color: #172033; }
.product-code { color: #718096; font-size: 13px; }
.product-stats { display: flex; align-items: center; gap: 16px; }
.stat { color: #59677b; font-size: 13px; }
.stat b { color: #172033; font-size: 15px; }
.stat-success b { color: #1a9c5b; }
.stat-muted b { color: #98a2b3; }
.range-text { font-variant-numeric: tabular-nums; color: #2b3a55; }
.row-actions { display: flex; align-items: center; justify-content: center; gap: 4px; }
.muted { color: #98a2b3; }
.placeholder-box { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 0; color: #98a2b3; }
.placeholder-icon { font-size: 30px; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 44px 0 36px; text-align: center; }
.empty-icon { font-size: 40px; color: #c2cddb; }
.empty-title { margin: 4px 0 0; color: #3b4a60; font-size: 15px; font-weight: 600; }
.empty-desc { margin: 0; color: #98a2b3; font-size: 13px; }
.empty-actions { display: flex; gap: 10px; margin-top: 12px; }
.modal-product { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; padding: 10px 14px; border-radius: 8px; background: #f5f8fd; }
.modal-product-label { color: #718096; font-size: 13px; }
.modal-product-value { color: #172033; font-size: 14px; font-weight: 600; }
.form-section + .form-section { margin-top: 6px; }
.form-section-title { margin-bottom: 10px; padding-left: 8px; border-left: 3px solid #1677ff; color: #2b3a55; font-size: 14px; font-weight: 600; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.range-row { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: 0 10px; }
.range-separator { padding-top: 32px; color: #98a2b3; }
.full-width { width: 100%; }
@media (max-width: 720px) {
  .range-page { padding: 14px; }
  .page-header { display: block; }
  .header-badge { margin-top: 14px; }
  .toolbar, .product-summary { align-items: flex-start; }
  .form-grid, .range-row { grid-template-columns: 1fr; }
  .range-separator { display: none; }
}
</style>
src/views/mobile/home.vue
@@ -27,7 +27,7 @@
        <h2 class="m-title">从一次扫码开始</h2>
        <RouterLink v-if="items.some(f=>f.key==='inbound')" class="m-primary" to="/mobile/inbound"><Icon name="scan"/>扫码入库</RouterLink>
        <RouterLink v-else :to="'/mobile/'+items[0]!.key" class="m-primary">{{items[0]!.title}}</RouterLink>
        <p class="m-muted" style="margin:12px 0 0">入库前核对对象、数量和库位,提交后查看处理结果。</p>
        <p class="m-muted" style="margin:12px 0 0">入库前核对对象和数量,提交后查看处理结果。</p>
      </div>
    </template>
    <h2 class="m-section-title">{{route.path.endsWith('/work')?'全部作业':'常用功能'}}</h2>
src/views/mobile/inbound.vue
@@ -3,49 +3,33 @@
import { onBeforeRouteLeave } from 'vue-router';
import { useUserStore } from '@vben/stores';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import { getWarehouseLocationSimpleList, type MesWmWarehouseLocationApi } from '#/api/mes/wm/warehouse/location';
import { getWarehouseAreaSimpleList, type MesWmWarehouseAreaApi } from '#/api/mes/wm/warehouse/area';
import { inboundScanEvent, type MesProScanEventApi } from '#/api/mes/pro/scanEvent';
import { lookup, myEvent, errorText, writeBlockReason, type TraceResult } from '#/mobile/api';
import Scanner from './scanner.vue';
import Picker from './picker.vue';
import { speakDuplicateScan } from '#/mobile/scanVoice';
const user=useUserStore(),storageKey='mom-mobile-inbound-'+user.userInfo?.id;
const positionKey='mom-mobile-position-'+user.userInfo?.id;
const locations=ref<MesWmWarehouseLocationApi.WarehouseLocation[]>([]),areas=ref<MesWmWarehouseAreaApi.WarehouseArea[]>([]);
const locationId=ref<number>(),areaId=ref<number>(),busy=ref(false),error=ref(''),trace=ref<TraceResult>();
const targetName=ref('');
const busy=ref(false),error=ref(''),trace=ref<TraceResult>();
const quantity=ref(1),result=ref<MesProScanEventApi.ScanEvent>(),pending=ref<MesProScanEventApi.InboundParams>(),unknown=ref(false),ready=ref(false);
const remembered=ref('');
const areaLoading=ref(false),positionLoading=ref(false);
let areaRequest=0;
const place=computed(()=>[locations.value.find(l=>l.id===locationId.value)?.name,areas.value.find(a=>a.id===areaId.value)?.name].filter(Boolean).join(' / ')||remembered.value);
async function loadAreas(){
  const ticket=++areaRequest, id=locationId.value;
  areas.value=[];areaId.value=undefined;areaLoading.value=!!id;
  try{if(id){const list=await getWarehouseAreaSimpleList(id);if(ticket===areaRequest)areas.value=list.filter(a=>!a.frozen&&(a.status==null||a.status===0));}}
  finally{if(ticket===areaRequest)areaLoading.value=false;}
}
async function changeLocation(){error.value='';trace.value=undefined;try{await loadAreas();}catch(e){error.value=errorText(e);}}
async function loadPositions(){
const positionLoading=ref(false);
const place=computed(()=>targetName.value||'生产暂存仓');
async function loadTarget(){
  if(positionLoading.value)return;positionLoading.value=true;
  error.value='';
  try{
    const warehouses=await getWarehouseSimpleList();
    const wip=warehouses.find(w=>w.code==='WIP_VIRTUAL_WAREHOUSE');
    if(!wip?.id)throw new Error('未配置生产暂存仓,请联系管理员');
    locations.value=(await getWarehouseLocationSimpleList(wip.id)).filter(l=>!l.frozen);
    const saved=JSON.parse(sessionStorage.getItem(positionKey)||'null');
    if(saved&&locations.value.some(l=>l.id===saved.locationId)){locationId.value=saved.locationId;await loadAreas();if(areas.value.some(a=>a.id===saved.areaId))areaId.value=saved.areaId;}
    targetName.value=wip.name||'生产暂存仓';
    ready.value=true;
  }catch(e){error.value=errorText(e);}finally{positionLoading.value=false;}
}
onMounted(async()=>{
  try {
    const savedPosition=JSON.parse(sessionStorage.getItem(positionKey)||'null');
    remembered.value=savedPosition?.name||'';
    const savedPending=JSON.parse(sessionStorage.getItem(storageKey)||'null');
    if(savedPending){pending.value=savedPending;unknown.value=true;return;}
  } catch { error.value='上次操作信息无法读取,请通过作业记录核实后再入库。'; return; }
  await loadPositions();
  await loadTarget();
});
function eventId(){
  const bytes=crypto.getRandomValues(new Uint8Array(16));
@@ -56,25 +40,25 @@
  try{
    const found=await lookup(code),reason=writeBlockReason(found);
    if(reason)throw new Error(reason);
    if (found.type === 'bag' && found.data.inboundTime || found.type !== 'bag' && found.data.pendingBagQuantity === 0) throw new Error('该对象没有待入库袋码,请勿重复入库');
    if (found.type === 'bag' && found.data.inboundTime || found.type !== 'bag' && found.data.pendingBagQuantity === 0) { speakDuplicateScan(); throw new Error('该对象没有待入库袋码,请勿重复入库'); }
    trace.value=found;quantity.value=1;
  }catch(e){error.value=errorText(e);}finally{busy.value=false;}
}
function complete(r:MesProScanEventApi.ScanEvent){
  result.value=r;unknown.value=false;pending.value=undefined;sessionStorage.removeItem(storageKey);
  error.value=r.status==='SUCCESS'?'':(r.errorMessage||'入库失败');
  if(r.status!=='SUCCESS' && /已入库|重复入库/.test(r.errorMessage||'')) speakDuplicateScan();
}
async function submit(){
  if(busy.value)return;
  if(!pending.value){
    if(!trace.value||!locationId.value||!areaId.value){error.value='请先选择库区、库位并识别码值';return;}
    if(!trace.value){error.value='请先识别码值';return;}
    if(!Number.isFinite(quantity.value)||quantity.value<=0){error.value='入库数量必须大于零';return;}
    pending.value={eventId:eventId(),bagCode:trace.value.code,scanType:trace.value.type.toUpperCase() as MesProScanEventApi.ScanType,quantity:quantity.value,locationId:locationId.value,areaId:areaId.value};
    sessionStorage.setItem(positionKey,JSON.stringify({locationId:locationId.value,areaId:areaId.value,name:place.value}));
    pending.value={eventId:eventId(),bagCode:trace.value.code,scanType:trace.value.type.toUpperCase() as MesProScanEventApi.ScanType,quantity:quantity.value};
  }
  sessionStorage.setItem(storageKey,JSON.stringify(pending.value));
  busy.value=true;error.value='';
  try{complete(await inboundScanEvent(pending.value));}
  try{complete(await inboundScanEvent(pending.value!));}
  catch(e){unknown.value=true;error.value='结果待确认:'+errorText(e)+'。请先核实原操作,勿重新扫码入库。';}
  finally{busy.value=false;}
}
@@ -83,13 +67,13 @@
  try{const r=await myEvent(pending.value.eventId);if(r)complete(r);else error.value='尚未查到记录。请稍后核实,或使用原操作号重试。';}
  catch(e){error.value=errorText(e);}finally{busy.value=false;}
}
function next(){result.value=undefined;trace.value=undefined;error.value='';if(!ready.value)void loadPositions();}
function next(){result.value=undefined;trace.value=undefined;error.value='';if(!ready.value)void loadTarget();}
onBeforeRouteLeave(()=>!busy.value&&(!trace.value||!!result.value||unknown.value||window.confirm('离开将放弃尚未提交的对象,是否继续?')));
</script>
<template>
  <p class="m-muted">生产暂存入库 Â· è´¨æ£€å‰æš‚å­˜</p>
  <div v-if="pending && unknown" class="m-panel">
    <h2 class="m-title">核实上次入库结果</h2><p>{{pending.bagCode}}</p><p>目标位置:{{place||('库区 '+pending.locationId+' / åº“位 '+pending.areaId)}}</p><p class="m-muted">操作号:{{pending.eventId}}</p>
    <h2 class="m-title">核实上次入库结果</h2><p>{{pending.bagCode}}</p><p>目标仓库:{{place}}</p><p class="m-muted">操作号:{{pending.eventId}}</p>
    <p class="m-warning">同一操作重试将沿用原操作号,不创建新的入库请求。</p>
    <div class="m-stack"><button class="m-primary" :disabled="busy" @click="verify">核实结果</button><button class="m-secondary" :disabled="busy" @click="submit">使用原操作号重试</button></div>
  </div>
@@ -100,16 +84,15 @@
  </template>
  <template v-else>
    <div v-if="!trace" class="m-panel">
      <h2 class="m-title">目标位置</h2>
      <Picker v-model="locationId" label="库区" :options="locations" :disabled="busy||!!trace" :loading="positionLoading" placeholder="请选择库区" @update:model-value="changeLocation"/>
      <Picker v-model="areaId" label="库位" :options="areas" :disabled="busy||!!trace||!locationId" :loading="areaLoading" placeholder="请选择库位"/>
      <h2 class="m-title">目标仓库</h2>
      <p>{{place}}</p>
      <p v-if="!ready" class="m-muted">正在获取作业位置</p>
      <button v-if="!ready&&error" class="m-secondary" @click="loadPositions">重新获取位置</button>
      <button v-if="!ready&&error" class="m-secondary" @click="loadTarget">重新获取位置</button>
    </div>
    <Scanner v-if="!trace" :disabled="busy||!areaId||!ready" @code="identify"/>
    <Scanner v-if="!trace" duplicate-voice :disabled="busy||!ready" @code="identify"/>
    <div v-else class="m-panel">
      <h2 class="m-title">核对入库对象</h2>
      <dl><div class="m-row"><dt>品种</dt><dd>{{trace.data.itemName||'未录入'}}</dd></div><div class="m-row"><dt>码值</dt><dd>{{trace.code}}</dd></div><div class="m-row"><dt>类型</dt><dd>{{{bag:'袋码',pallet:'托盘码',batch:'批次码'}[trace.type]}}</dd></div><div class="m-row"><dt>目标位置</dt><dd>{{place}}</dd></div><div class="m-row"><dt>批次</dt><dd>{{trace.data.batchCode||'未绑定'}}</dd></div></dl>
      <dl><div class="m-row"><dt>品种</dt><dd>{{trace.data.itemName||'未录入'}}</dd></div><div class="m-row"><dt>码值</dt><dd>{{trace.code}}</dd></div><div class="m-row"><dt>类型</dt><dd>{{{bag:'袋码',pallet:'托盘码',batch:'批次码'}[trace.type]}}</dd></div><div class="m-row"><dt>目标仓库</dt><dd>{{place}}</dd></div><div class="m-row"><dt>批次</dt><dd>{{trace.data.batchCode||'未绑定'}}</dd></div></dl>
      <label v-if="trace.type==='bag'" class="m-field"><span>入库数量(按库存计量单位)</span><input v-model.number="quantity" type="number" inputmode="decimal" min="0.000001" step="any" :disabled="busy"/></label>
      <p v-else class="m-warning">整托 / æ•´æ‰¹å…¥åº“,本次待入库 {{trace.data.pendingBagQuantity ?? '—'}} è¢‹ï¼Œæœ€ç»ˆç”±æœåŠ¡ç«¯æŒ‰æœªå…¥åº“æœ‰æ•ˆè¢‹æ•°è®¡ç®—ã€‚</p>
      <div class="m-action-bar"><button class="m-primary" :disabled="busy" @click="submit">{{busy?'正在入库…':'确认入库'}}</button><button class="m-secondary" :disabled="busy" @click="next">返回重新扫描</button></div>
src/views/mobile/miscissue.vue
@@ -133,8 +133,6 @@
      batchCode: matched.batchCode,
      batchId: matched.batchId,
      warehouseId: matched.warehouseId,
      locationId: matched.locationId,
      areaId: matched.areaId,
      remark: `扫码来源 ${trace.value?.code ?? ''}`,
    });
    notice.value = '已添加出库明细。';
src/views/mobile/quality-records.vue
@@ -6,7 +6,6 @@
import {
  approveSeedQualityBatch,
  getSeedIndicatorRanges,
  getSeedQualityPage,
  resolveSeedQuality,
  resolveSeedRetestScope,
@@ -33,44 +32,27 @@
const rows = ref<SeedQualityRecord[]>([]), busy = ref(false), error = ref(''), notice = ref('');
const scanType = ref<'BATCH' | 'PALLET'>('PALLET'), scannedCode = ref('');
const qcLine = ref<1 | 2>(1);
const scope = ref<SeedRetestScope>(), initialScope = ref<SeedQualityScan>();
const workflowStatus = ref<10 | 50>(10), reason = ref('');
const warehouseId = ref<number>();
const warehouseOptions = ref<Array<{ label: string; value: number }>>([]);
const reviewGroup = ref<ReviewGroup>(), reviewApproved = ref(true), reviewOpinion = ref('');
const indicators = ref([
  { code: 'purity', name: '纯度', value: '', minValue: '', maxValue: '' },
  { code: 'cleanliness', name: '净度', value: '', minValue: '', maxValue: '' },
  { code: 'germination_rate', name: '发芽率', value: '', minValue: '', maxValue: '' },
  { code: 'moisture', name: '水分', value: '', minValue: '', maxValue: '' },
]);
const indicators = ref<Array<{ code: string; name: string; unit: string; value: string; minValue: string; maxValue: string }>>([]);
const initialIds = computed(() => initialScope.value?.pendingBagIds || []);
const targetIds = computed(() => workflowStatus.value === 10 ? scope.value?.retestIds || [] : scope.value?.initialRecheckIds || []);
const complete = computed(() => indicators.value.every((item) =>
const complete = computed(() => indicators.value.length > 0 && indicators.value.every((item) =>
  [item.value, item.minValue, item.maxValue].every((value) => String(value).trim() !== '' && Number.isFinite(Number(value)))
  && Number(item.value) >= 0 && Number(item.value) <= 100 && Number(item.minValue) >= 0
  && Number(item.maxValue) <= 100 && Number(item.minValue) <= Number(item.maxValue)));
  && Number(item.value) >= 0 && Number(item.minValue) >= 0
  && Number(item.maxValue) >= 0 && Number(item.minValue) <= Number(item.maxValue)));
const passed = computed(() => complete.value && indicators.value.every((item) =>
  Number(item.value) >= Number(item.minValue) && Number(item.value) <= Number(item.maxValue)));
const reviewGroups = computed<ReviewGroup[]>(() => {
  if (!scope.value) return [];
  return [
    { status: 20, stage: 'process' as const, ids: scope.value.pendingProcessIds || [] },
    { status: 25, stage: 'process' as const, ids: scope.value.pendingInitialFailIds || [] },
    { status: 30, stage: 'company' as const, ids: scope.value.pendingCompanyIds || [] },
    { status: 60, stage: 'company' as const, ids: scope.value.pendingCompanyFailIds || [] },
  ].filter((group) => group.ids.length && (group.stage === 'process' ? canProcessReview.value : canCompanyReview.value));
});
const reviewGroups = computed<ReviewGroup[]>(() => []);
const statusNames: Record<number, string> = {
  10: '待复检', 20: '待初检审核', 25: '待初检不合格处理', 30: '待复检审核',
  40: '已放行', 50: '待重新初检', 60: '待复检不合格处理', 70: '已报废',
  10: '待复检', 20: '待部门审批', 25: '待部门不合格审批', 30: '待公司质检审批',
  40: '已放行', 50: '待重新初检', 60: '待公司质检不合格审批', 70: '已报废',
};
function resetIndicators() {
  indicators.value.forEach((item) => { item.value = ''; });
  reason.value = '';
}
async function hydrateReviewIds(current: SeedRetestScope, rawCode: string) {
  const loaded = (current.pendingProcessIds?.length || 0) + (current.pendingInitialFailIds?.length || 0)
@@ -99,13 +81,18 @@
  try {
    const codeValue = extractCode(raw);
    scannedCode.value = codeValue;
    const current = await resolveSeedRetestScope(codeValue, scanType.value);
    const current = await resolveSeedRetestScope(codeValue, scanType.value, qcLine.value);
    await hydrateReviewIds(current, codeValue);
    scope.value = current;
    scanType.value = current.scanType;
    workflowStatus.value = current.retestIds.length ? 10 : 50;
    initialScope.value = await resolveSeedQuality(codeValue, current.scanType);
    resetIndicators();
    initialScope.value = await resolveSeedQuality(codeValue, current.scanType, qcLine.value);
    const source = current.indicators?.length ? current.indicators : initialScope.value?.indicators;
    indicators.value = (source || []).map((item) => ({
      code: item.code, name: item.name || item.code, unit: item.unit || '%',
      value: '', minValue: String(item.minValue), maxValue: String(item.maxValue),
    }));
    reason.value = '';
    if (!initialIds.value.length && !targetIds.value.length && !reviewGroups.value.length) {
      notice.value = '当前托盘或生产批次没有可处理的质检记录。';
    }
@@ -131,11 +118,12 @@
  if (!window.confirm(`确认批量提交 ${ids.length} è¢‹åˆæ£€ï¼Ÿ`)) return;
  busy.value = true; error.value = '';
  try {
    const result = await submitSeedQualityGroup({ code: initialScope.value.code, batchCode: initialScope.value.batchCode,
    const result = await submitSeedQualityGroup({ code: initialScope.value.code,
      batchCode: initialScope.value.batchCode,
      scanType: initialScope.value.scanType || scanType.value, bagIds: ids, qualifiedWarehouseId: warehouseId.value,
      reason: reason.value.trim() || undefined, indicators: indicatorPayload() });
    busy.value = false;
    await refreshScopeAndList(`已完成 ${result.processedCount} è¢‹åˆæ£€ã€‚`);
    await refreshScopeAndList(`已完成 ${result.processedCount} è¢‹åˆæ£€ï¼Œå·²åŒæ­¥ç”ŸæˆåŠ å·¥éƒ¨ã€é”€å”®éƒ¨ä¸¤æ¡è®°å½•ã€‚`);
  } catch (exception) { error.value = errorText(exception) + '。请重新识别后核对待检清单。'; }
  finally { busy.value = false; }
}
@@ -147,7 +135,7 @@
  if (!window.confirm(`确认将本次指标用于 ${ids.length} è¢‹${workflowStatus.value === 10 ? '复检' : '重新初检'}?`)) return;
  busy.value = true; error.value = '';
  try {
    const result = await submitSeedRetestScope({ code: scope.value.code, scanType: scope.value.scanType,
    const result = await submitSeedRetestScope({ code: scope.value.code, scanType: scope.value.scanType, qcLine: qcLine.value,
      workflowStatus: workflowStatus.value, qualityIds: ids, reason: reason.value.trim() || undefined, indicators: indicatorPayload() });
    busy.value = false;
    await refreshScopeAndList(`已完成 ${result.processedCount} è¢‹æ£€éªŒã€‚`);
@@ -198,13 +186,6 @@
      if (warehouseOptions.value.length === 1) warehouseId.value = warehouseOptions.value[0]?.value;
    } catch { warehouseOptions.value = []; }
  }
  if (canInitial.value || canRetest.value) {
    try {
      const ranges = await getSeedIndicatorRanges();
      indicators.value.forEach((item) => { const range = ranges.find((row) => row.indicatorCode === item.code);
        if (range) { item.minValue = String(range.minValue); item.maxValue = String(range.maxValue); } });
    } catch { /* åˆæ ¼èŒƒå›´ä»å¯æ‰‹å·¥å¡«å†™ */ }
  }
  await load();
});
</script>
@@ -214,17 +195,19 @@
    <h2 class="m-title">批量检验与审核</h2>
    <p class="m-muted">扫描托盘码或生产批次码,系统会按当前账号权限列出批量初检、审核、复检操作。</p>
    <label class="m-field"><span>扫码类型</span><select v-model="scanType" :disabled="busy" @change="scope = undefined; initialScope = undefined"><option value="PALLET">托盘码</option><option value="BATCH">生产批次码</option></select></label>
    <label class="m-field"><span>质检线(复检/审核)</span><select v-model.number="qcLine" :disabled="busy" @change="scope = undefined; initialScope = undefined"><option :value="1">加工部</option><option :value="2">销售部</option></select></label>
    <p class="m-muted">初检提交会同时生成加工部、销售部两条记录;质检线仅用于筛选复检和审核范围。</p>
    <Scanner :disabled="busy" @code="identifyScope" />
    <template v-if="scope">
      <div class="m-panel"><h3 class="m-title">已识别范围</h3><dl><div class="m-row"><dt>码值</dt><dd>{{ scope.code }}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{ scope.batchCode || '—' }}</dd></div><div class="m-row"><dt>有效袋数</dt><dd>{{ scope.totalCount }}</dd></div></dl></div>
      <form v-if="canInitial && initialIds.length" class="m-panel" @submit.prevent="submitInitial">
        <h3 class="m-title">批量初检({{ Math.min(initialIds.length, 500) }} è¢‹ï¼‰</h3>
        <h3 class="m-title">批量初检({{ Math.min(initialIds.length, 500) }} è¢‹ï¼ŒåŒæ—¶ç”ŸæˆåŒçº¿è®°å½•)</h3>
        <p v-if="!initialScope?.inbound" class="m-warning">该范围的有效袋码尚未全部入库,暂不能提交初检。</p>
        <template v-else>
          <label class="m-field"><span>目标合格库</span><select v-model="warehouseId" required :disabled="busy"><option :value="undefined" disabled>请选择合格库</option><option v-for="item in warehouseOptions" :key="item.value" :value="item.value">{{ item.label }}</option></select></label>
          <fieldset v-for="item in indicators" :key="`initial-${item.code}`" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{ item.name }}(%)</legend><label class="m-field"><span>实测值</span><input v-model="item.value" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="item.minValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="item.maxValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label></div></fieldset>
          <fieldset v-for="item in indicators" :key="`initial-${item.code}`" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{ item.name }}({{ item.unit || '%' }})</legend><label class="m-field"><span>实测值</span><input v-model="item.value" type="number" inputmode="decimal" min="0" step="any" required /></label><div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="item.minValue" type="number" inputmode="decimal" min="0" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="item.maxValue" type="number" inputmode="decimal" min="0" step="any" required /></label></div></fieldset>
          <label class="m-field"><span>{{ complete && !passed ? '不合格原因(必填)' : '检验备注' }}</span><textarea v-model="reason" rows="3" maxlength="500" /></label>
          <button class="m-primary" :disabled="busy || !complete || !warehouseId">{{ busy ? '正在提交…' : `提交 ${Math.min(initialIds.length, 500)} è¢‹åˆæ£€` }}</button>
        </template>
@@ -244,7 +227,7 @@
        <h3 class="m-title">批量复检 / é‡æ–°åˆæ£€</h3>
        <label class="m-field"><span>质检阶段</span><select v-model="workflowStatus" :disabled="busy"><option v-if="scope.retestIds.length" :value="10">复检({{ scope.retestIds.length }} è¢‹ï¼‰</option><option v-if="scope.initialRecheckIds.length" :value="50">重新初检({{ scope.initialRecheckIds.length }} è¢‹ï¼‰</option></select></label>
        <p class="m-warning">同一组指标将用于本次 {{ Math.min(targetIds.length, 500) }} è¢‹ï¼Œè¯·æ ¸å¯¹æŠ½æ ·ç»“果。</p>
        <fieldset v-for="item in indicators" :key="`retest-${item.code}`" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{ item.name }}(%)</legend><label class="m-field"><span>实测值</span><input v-model="item.value" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="item.minValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="item.maxValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label></div></fieldset>
        <fieldset v-for="item in indicators" :key="`retest-${item.code}`" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{ item.name }}({{ item.unit || '%' }})</legend><label class="m-field"><span>实测值</span><input v-model="item.value" type="number" inputmode="decimal" min="0" step="any" required /></label><div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="item.minValue" type="number" inputmode="decimal" min="0" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="item.maxValue" type="number" inputmode="decimal" min="0" step="any" required /></label></div></fieldset>
        <label class="m-field"><span>{{ complete && !passed ? '不合格原因(必填)' : '检验备注' }}</span><textarea v-model="reason" rows="3" maxlength="500" /></label>
        <button class="m-primary" :disabled="busy || !complete || !targetIds.length">{{ busy ? '正在提交…' : `提交 ${Math.min(targetIds.length, 500)} è¢‹æ£€éªŒ` }}</button>
      </form>
@@ -257,7 +240,7 @@
  <p class="m-muted">查看质检结果、批次与不合格原因。</p>
  <Scanner :disabled="busy" @code="value => { code = value; load(); }" />
  <form class="m-panel" @submit.prevent="load()">
    <label class="m-field"><span>业务码 / è¿½æº¯ç </span><input v-model="code" :disabled="busy" placeholder="输入袋码" /></label>
    <label class="m-field"><span>追溯码</span><input v-model="code" :disabled="busy" placeholder="输入袋码" /></label>
    <label class="m-field"><span>生产批次</span><input v-model="batchCode" :disabled="busy" placeholder="输入批次号" /></label>
    <label class="m-field"><span>质检结果</span><select v-model="result" :disabled="busy"><option value="">全部结果</option><option value="1">合格</option><option value="2">不合格</option></select></label>
    <button class="m-secondary" :disabled="busy">{{ busy ? '正在查询…' : '查询记录' }}</button>
src/views/mobile/quality.vue
@@ -2,11 +2,12 @@
import { computed, onMounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { useUserStore } from '@vben/stores';
import { getSeedIndicatorRanges, resolveSeedQuality, submitSeedQualityGroup, type SeedQualityScan } from '#/api/mes/qc/seedQuality';
import { resolveSeedQuality, submitSeedQualityGroup, type SeedQualityScan } from '#/api/mes/qc/seedQuality';
import { getWarehouseSimpleList, type MesWmWarehouseApi } from '#/api/mes/wm/warehouse';
import { allowedMobilePath, extractCode } from '#/mobile/access';
import { errorText } from '#/mobile/api';
import Scanner from './scanner.vue';
import { speakDuplicateScan } from '#/mobile/scanVoice';
import Picker from './picker.vue';
const busy = ref(false), error = ref(''), notice = ref(''), bag = ref<SeedQualityScan>();
@@ -17,13 +18,8 @@
const typeNames = { BAG: '袋码', PALLET: '托盘码', BATCH: '生产批次码' };
const pendingType = ref<ScanType>('BAG'), pendingIds = ref<number[]>([]);
const storageKey = 'mom-mobile-quality-' + useUserStore().userInfo?.id;
const indicators = ref([
  { code: 'purity', name: '纯度', value: '', minValue: '', maxValue: '' },
  { code: 'cleanliness', name: '净度', value: '', minValue: '', maxValue: '' },
  { code: 'germination_rate', name: '发芽率', value: '', minValue: '', maxValue: '' },
  { code: 'moisture', name: '水分', value: '', minValue: '', maxValue: '' },
]);
const complete = computed(() => indicators.value.every(i => [i.value, i.minValue, i.maxValue].every(v => String(v).trim() !== '' && Number.isFinite(Number(v))) && Number(i.minValue) >= 0 && Number(i.maxValue) <= 100 && Number(i.value) >= 0 && Number(i.value) <= 100 && Number(i.minValue) <= Number(i.maxValue)));
const indicators = ref<Array<{ code: string; name: string; unit: string; value: string; minValue: string; maxValue: string }>>([]);
const complete = computed(() => indicators.value.length > 0 && indicators.value.every(i => [i.value, i.minValue, i.maxValue].every(v => String(v).trim() !== '' && Number.isFinite(Number(v))) && Number(i.minValue) >= 0 && Number(i.maxValue) >= 0 && Number(i.value) >= 0 && Number(i.minValue) <= Number(i.maxValue)));
const passed = computed(() => complete.value && indicators.value.every(i => Number(i.value) >= Number(i.minValue) && Number(i.value) <= Number(i.maxValue)));
function clearPending() { pendingCode.value = ''; pendingIds.value = []; sessionStorage.removeItem(storageKey); }
async function identify(raw: string) {
@@ -32,27 +28,29 @@
  try {
    const data = await resolveSeedQuality(extractCode(raw), scanType.value);
    if (data.canceled || !data.inbound) throw new Error('该对象已作废或有效袋码尚未全部入库,不能质检');
    if (!data.pendingBagIds?.length) throw new Error('该对象没有待质检袋码,请在质检记录中查看已有结果');
    bag.value = data; indicators.value.forEach(i => { i.value = ''; i.minValue = ''; i.maxValue = ''; }); reason.value = '';
    try {
      const ranges = await getSeedIndicatorRanges();
      indicators.value.forEach(i => { const range = ranges.find(r => r.indicatorCode === i.code); if (range) { i.minValue = String(range.minValue); i.maxValue = String(range.maxValue); } });
    } catch { /* æ¨¡æ¿ä¸å¯ç”¨æ—¶ä»å…è®¸æ‰‹åŠ¨å¡«å†™æœ¬æ¬¡èŒƒå›´ */ }
    if (!data.pendingBagIds?.length) { if (data.completedBagIds?.length) speakDuplicateScan('quality'); throw new Error('该对象没有待质检袋码,请在质检记录中查看已有结果'); }
    if (!data.indicators?.length) throw new Error('该产品尚未配置质检项,请联系管理员维护');
    bag.value = data;
    indicators.value = data.indicators.map(i => ({
      code: i.code, name: i.name || i.code, unit: i.unit || '%',
      value: '', minValue: String(i.minValue), maxValue: String(i.maxValue),
    }));
    reason.value = '';
  } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
}
async function submit() {
  if (busy.value || pendingCode.value || !bag.value || !complete.value || !warehouseId.value) return;
  if (!passed.value && !reason.value.trim()) { error.value = '请填写不合格原因'; return; }
  const count = bag.value.pendingBagIds!.length;
  if (!window.confirm(passed.value ? `确认对本次 ${count} è¢‹ç»Ÿä¸€æäº¤åˆæ£€åˆæ ¼ï¼Ÿå®¡æ ¸é€šè¿‡åŽè¿›å…¥å¤æ£€ã€‚` : `确认对本次 ${count} è¢‹ç»Ÿä¸€æäº¤åˆæ£€ä¸åˆæ ¼ï¼Ÿå°†è¿›å…¥ä¸åˆæ ¼å¤„理。`)) return;
  if (!window.confirm(passed.value ? `确认对本次 ${count} è¢‹æäº¤åˆæ£€åˆæ ¼ï¼Ÿå°†åŒæ—¶ç”ŸæˆåŠ å·¥éƒ¨ã€é”€å”®éƒ¨ä¸¤æ¡è®°å½•å¹¶è¿›å…¥éƒ¨é—¨å®¡æ‰¹ã€‚` : `确认对本次 ${count} è¢‹æäº¤åˆæ£€ä¸åˆæ ¼ï¼Ÿå°†åŒæ—¶ç”ŸæˆåŠ å·¥éƒ¨ã€é”€å”®éƒ¨ä¸¤æ¡è®°å½•å¹¶è¿›å…¥å¤æ£€ã€‚`)) return;
  error.value = ''; notice.value = '';
  pendingCode.value = bag.value.code; pendingType.value = scanType.value; pendingIds.value = [...bag.value.pendingBagIds!];
  sessionStorage.setItem(storageKey, JSON.stringify({ code: pendingCode.value, scanType: pendingType.value, bagIds: pendingIds.value })); busy.value = true;
  try {
    const result = await submitSeedQualityGroup({ code: bag.value.code, scanType: scanType.value, bagIds: pendingIds.value, qualifiedWarehouseId: warehouseId.value, reason: reason.value.trim() || undefined, indicators: indicators.value.map(i => ({ code: i.code, value: Number(i.value), minValue: Number(i.minValue), maxValue: Number(i.maxValue) })) });
    clearPending(); notice.value = `本次 ${result.processedCount} è¢‹` + (passed.value ? '初检合格,等待审核。' : '初检不合格,等待不合格处理。');
    clearPending(); notice.value = `本次 ${result.processedCount} è¢‹` + (passed.value ? '初检合格,已同步生成加工部、销售部两条记录并进入部门审批。' : '初检不合格,已同步生成加工部、销售部两条记录,等待复检。');
    bag.value = undefined; reason.value = ''; indicators.value.forEach(i => i.value = '');
  } catch (e) { error.value = '提交结果待确认:' + errorText(e) + '。请核实后再操作下一袋。'; } finally { busy.value = false; }
  } catch (e) { if (/已质检|已全部质检/.test(errorText(e))) speakDuplicateScan('quality'); error.value = '提交结果待确认:' + errorText(e) + '。请核实后再操作下一袋。'; } finally { busy.value = false; }
}
async function verify() {
  if (busy.value || !pendingCode.value) return;
@@ -83,16 +81,16 @@
  <div v-if="pendingCode" class="m-panel"><h2 class="m-title">质检结果待确认</h2><p>{{pendingCode}}</p><button class="m-primary" :disabled="busy" @click="verify">核实质检结果</button></div>
  <template v-else>
    <label class="m-field"><span>质检扫码类型</span><select v-model="scanType" :disabled="busy" @change="bag = undefined; notice = ''; error = ''"><option value="BAG">袋码</option><option value="PALLET">托盘码</option><option value="BATCH">生产批次码</option></select></label>
    <p class="m-muted">扫描{{typeNames[scanType]}},核对待检袋数,统一录入四项指标。已检、作废袋码不会重复处理。</p>
    <Scanner :disabled="busy" @code="identify" />
    <p class="m-muted">扫描{{typeNames[scanType]}},核对待检袋数,统一录入该产品的全部质检项。一次提交会同时生成加工部、销售部两条质检记录;已检、作废袋码不会重复处理。</p>
    <Scanner voice-operation="quality" duplicate-voice :disabled="busy" @code="identify" />
    <form v-if="bag" @submit.prevent="submit">
      <div class="m-panel"><h2 class="m-title">本次检验对象</h2><dl><div class="m-row"><dt>对象类型</dt><dd>{{typeNames[scanType]}}</dd></div><div class="m-row"><dt>对象码值</dt><dd>{{bag.code}}</dd></div><div class="m-row"><dt>业务码</dt><dd>{{bag.businessCode || '—'}}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{bag.batchCode || '—'}}</dd></div><div class="m-row"><dt>有效袋数</dt><dd>{{bag.totalCount}}</dd></div><div class="m-row"><dt>已检袋数</dt><dd>{{bag.completedBagIds?.length || 0}}</dd></div><div class="m-row"><dt>本次待检</dt><dd>{{bag.pendingBagIds?.length || 0}} è¢‹</dd></div></dl><p v-if="scanType !== 'BAG'" class="m-warning">以下指标和检验结论将应用于本次全部待检袋码,请确认抽样结果适用于这{{bag.pendingBagIds?.length}}袋。</p></div>
      <fieldset v-for="i in indicators" :key="i.code" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{i.name}}(%)</legend>
        <label class="m-field"><span>实测值</span><input v-model="i.value" type="number" inputmode="decimal" min="0" max="100" step="any" required placeholder="输入实测百分比" /></label>
        <div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="i.minValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="i.maxValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label></div>
      <div class="m-panel"><h2 class="m-title">本次检验对象</h2><dl><div class="m-row"><dt>对象类型</dt><dd>{{typeNames[scanType]}}</dd></div><div class="m-row"><dt>对象码值</dt><dd>{{bag.code}}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{bag.batchCode || '—'}}</dd></div><div class="m-row"><dt>有效袋数</dt><dd>{{bag.totalCount}}</dd></div><div class="m-row"><dt>已检袋数</dt><dd>{{bag.completedBagIds?.length || 0}}</dd></div><div class="m-row"><dt>本次待检</dt><dd>{{bag.pendingBagIds?.length || 0}} è¢‹</dd></div></dl><p v-if="scanType !== 'BAG'" class="m-warning">以下指标和检验结论将应用于本次全部待检袋码,请确认抽样结果适用于这{{bag.pendingBagIds?.length}}袋。</p></div>
      <fieldset v-for="i in indicators" :key="i.code" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{i.name}}({{i.unit || '%'}})</legend>
        <label class="m-field"><span>实测值</span><input v-model="i.value" type="number" inputmode="decimal" min="0" step="any" required placeholder="输入实测值" /></label>
        <div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="i.minValue" type="number" inputmode="decimal" min="0" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="i.maxValue" type="number" inputmode="decimal" min="0" step="any" required /></label></div>
      </fieldset>
      <div class="m-panel"><p class="m-muted">合格范围从模板带入,可在本次质检中修改;请按该品种标准核对。</p><Picker v-model="warehouseId" label="目标合格库" :options="warehouses" :disabled="busy" /><p v-if="!warehouses.length && !busy" class="m-warning">暂无可用合格库,请联系管理员配置。</p><label class="m-field"><span>{{complete && !passed ? '不合格原因(必填)' : '检验备注'}}</span><textarea v-model="reason" :disabled="busy" :required="complete && !passed" rows="3" maxlength="255" /></label></div>
      <div class="m-action-bar"><p v-if="complete" :class="passed ? 'm-success' : 'm-warning'">预判:{{passed ? '初检合格,等待初检审核' : '初检不合格,进入不合格处理'}}。最终以服务端结果为准。</p><button class="m-primary" :disabled="busy || !complete || !warehouseId">{{busy ? '正在提交…' : '确认提交质检'}}</button></div>
      <div class="m-action-bar"><p v-if="complete" :class="passed ? 'm-success' : 'm-warning'">预判:{{passed ? '初检合格,等待部门审批' : '初检不合格,进入复检'}}(同时生成加工部、销售部两条记录)。最终以服务端结果为准。</p><button class="m-primary" :disabled="busy || !complete || !warehouseId">{{busy ? '正在提交…' : '确认提交质检'}}</button></div>
    </form>
  </template>
  <RouterLink v-if="allowedMobilePath('/mobile/quality-records')" to="/mobile/quality-records" class="m-link">查看质检记录</RouterLink>
src/views/mobile/query.vue
@@ -8,6 +8,7 @@
import { can } from '#/mobile/access';
import { lookup, errorText, writeBlockReason, type TraceResult, type TraceObject } from '#/mobile/api';
import Scanner from './scanner.vue';
import { speakDuplicateScan } from '#/mobile/scanVoice';
defineOptions({name:'MobileQuery'});
const route=useRoute(),key=route.path.split('/')[2]||'bags';
const expected={bags:'bag',pallets:'pallet',batches:'batch'}[key];
@@ -39,9 +40,9 @@
    if((binding.value||autoPacking.value)&&writeBlockReason(r))throw new Error(writeBlockReason(r));
    if(autoPacking.value){
      if(r.type!=='bag')throw new Error('请扫描袋码');
      if(r.data.palletId)throw new Error('该袋码已绑定托盘,请核实后操作');
      if(r.data.palletId){speakDuplicateScan('bind');throw new Error('该袋码已绑定托盘,请核实后操作');}
      if(!r.data.itemId)throw new Error('该袋码缺少品种,无法绑定托盘');
      if(bags.value.some(b=>b.data.id===r.data.id))throw new Error('本次已经采集该袋码');
      if(bags.value.some(b=>b.data.id===r.data.id)){speakDuplicateScan('bind');throw new Error('本次已经采集该袋码');}
      const first=bags.value[0];
      if(first&&first.data.itemId!==r.data.itemId)throw new Error('袋码品种与本次托盘不一致');
      if(first&&first.data.batchId!==r.data.batchId)throw new Error('袋码所属批次与本次托盘不一致');
@@ -58,9 +59,9 @@
    }else{
      if(r.type!==expected)throw new Error('请扫描'+(key==='bags'?'袋码':'托盘码'));
      if(key==='bags'){
        if(r.data.palletId)throw new Error('该袋码已绑定托盘,请核实后操作');
        if(r.data.palletId){speakDuplicateScan('bind');throw new Error('该袋码已绑定托盘,请核实后操作');}
        if(r.data.batchId&&target.value.data.batchId&&r.data.batchId!==target.value.data.batchId)throw new Error('袋码与目标托盘批次不一致');
        if(bags.value.some(b=>b.data.id===r.data.id))throw new Error('本次已经采集该袋码');
        if(bags.value.some(b=>b.data.id===r.data.id)){speakDuplicateScan('bind');throw new Error('本次已经采集该袋码');}
        bags.value.push(r);
      }else{
        if(r.data.batchId)throw new Error('该托盘已绑定批次');
@@ -138,7 +139,7 @@
      <div v-if="target" class="m-panel"><p>目标:{{target.code}}</p><p class="m-muted">{{target.data.itemName||'未录入品种'}} Â· {{target.data.batchCode||'未绑定批次'}}</p></div>
    </template>
    <template v-if="autoPacking"><h2 class="m-title">扫描袋码生成托盘</h2><p class="m-muted">{{palletSize}} è¢‹ä¸ºæé†’节点 Â· å·²é‡‡é›† {{bags.length}} è¢‹ã€‚达到节点会提醒,可随时生成并绑定。</p></template>
    <Scanner :disabled="busy||thresholdPrompt" :continuous="autoPacking" @code="identify"/>
    <Scanner voice-operation="bind" :duplicate-voice="binding||autoPacking" :disabled="busy||thresholdPrompt" :continuous="autoPacking" @code="identify"/>
    <section v-if="autoPacking&&thresholdPrompt" class="m-panel" role="alert" aria-labelledby="pallet-threshold-title" aria-describedby="pallet-threshold-description">
      <h2 id="pallet-threshold-title" class="m-title">已达到 {{palletSize}} è¢‹æé†’节点</h2>
      <p id="pallet-threshold-description">可立即生成并绑定托盘码,也可继续扫描更多袋码加入本托盘。</p>
@@ -150,8 +151,7 @@
      <p v-else class="m-status">{{detail.data.inboundTime ? '已入库' : '未入库'}}<span v-if="detail.type === 'pallet'"> Â· å¾…入库 {{detail.data.pendingBagQuantity ?? '—'}} è¢‹</span></p>
      <p v-if="writeBlockReason(detail)" class="m-warning">{{writeBlockReason(detail)}}</p>
      <dl><div class="m-row"><dt>码值</dt><dd>{{detail.code}}</dd></div><div class="m-row"><dt>批次</dt><dd>{{detail.data.batchCode||detail.data.tempCode||'未绑定'}}</dd></div><div class="m-row"><dt>托盘</dt><dd>{{detail.data.palletCode||'—'}}</dd></div><div class="m-row"><dt>生产日期</dt><dd>{{detail.data.produceDate||'未录入'}}</dd></div><div class="m-row"><dt>产线</dt><dd>{{detail.data.lineName||'未录入'}}</dd></div></dl>
      <dl v-if="detail.type==='bag'"><div class="m-row"><dt>追溯码</dt><dd>{{detail.data.code}}</dd></div><div class="m-row"><dt>业务码</dt><dd>{{detail.data.businessCode||'未生成'}}</dd></div></dl>
      <dl v-else><div class="m-row"><dt>业务码</dt><dd>{{detail.data.businessCode||'未生成'}}</dd></div></dl>
      <dl v-if="detail.type==='bag'"><div class="m-row"><dt>追溯码</dt><dd>{{detail.data.code}}</dd></div></dl>
    </div>
    <template v-if="binding||autoPacking">
      <p v-if="key==='bags'" class="m-muted">已采集 {{bags.length}} è¢‹ï¼Œå°šæœªæäº¤ç»‘定</p>
src/views/mobile/returnissue.vue
@@ -180,7 +180,7 @@
      <dl>
        <div class="m-row"><dt>物料</dt><dd>{{ stock?.itemName || trace.data.itemName || '未录入' }}</dd></div>
        <div class="m-row"><dt>批次</dt><dd>{{ stock?.batchCode || trace.data.batchCode || '未绑定' }}</dd></div>
        <div class="m-row"><dt>库位</dt><dd>{{ [stock?.warehouseName, stock?.locationName, stock?.areaName].filter(Boolean).join(' / ') || '—' }}</dd></div>
        <div class="m-row"><dt>仓库</dt><dd>{{ stock?.warehouseName || '—' }}</dd></div>
        <div class="m-row"><dt>库存数量</dt><dd>{{ stock?.quantity ?? '—' }}</dd></div>
        <div class="m-row"><dt>码值</dt><dd style="overflow-wrap: anywhere">{{ trace.code }}</dd></div>
      </dl>
src/views/mobile/sales.vue
@@ -2,30 +2,47 @@
import { computed, onMounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { useUserStore } from '@vben/stores';
import { getProductSalesPage, getProductSales, previewProductSalesScan, scanProductSales, checkProductSalesQuantity, stockProductSales, shippingProductSales, finishProductSales, type MesWmProductSalesApi } from '#/api/mes/wm/productsales';
import { getProductSalesPage, getProductSales, getProductSalesShipments, closeProductSalesRemaining, cancelProductSalesShipment, previewProductSalesScan, scanProductSales, checkProductSalesQuantity, stockProductSales, shippingProductSales, finishProductSales, type MesWmProductSalesApi } from '#/api/mes/wm/productsales';
import { getProductSalesLinePage, type MesWmProductSalesLineApi } from '#/api/mes/wm/productsales/line';
import { getProductSalesDetailListByLineId, type MesWmProductSalesDetailApi } from '#/api/mes/wm/productsales/detail';
import { getProductSalesDetailListByLineId, createMobileManualDetail, deleteProductSalesDetail, type MesWmProductSalesDetailApi } from '#/api/mes/wm/productsales/detail';
import { can, extractCode } from '#/mobile/access';
import { errorText } from '#/mobile/api';
import Scanner from './scanner.vue';
import { speakDuplicateScan } from '#/mobile/scanVoice';
import PickingBags from '../wls/productsales/modules/picking-bags.vue';
import { formatPickingRemark } from '../wls/productsales/data';
import { getPickingStocks, type MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
type Sales = MesWmProductSalesApi.ProductSales;
type Preview = MesWmProductSalesApi.ProductSalesScanResponse;
type Line = MesWmProductSalesLineApi.ProductSalesLine;
type Detail = MesWmProductSalesDetailApi.ProductSalesDetail;
type Action = 'scan' | 'stock' | 'shipping' | 'finish';
type Pending = { salesId: number; action: Action; before: number; preview?: Preview; carrier?: string; shippingNumber?: string };
const statusNames: Record<number, string> = { 0: '草稿', 1: '待检测', 2: '待拣货', 10: '待填写运单', 3: '待执行出库', 4: '已完成', 5: '已取消' };
type Action = 'scan' | 'manual' | 'stock' | 'shipping' | 'finish' | 'close' | 'cancelShipment' | 'delete';
type Pending = { salesId: number; action: Action; before: number; preview?: Preview; carrier?: string; shippingNumber?: string; lineId?: number; materialStockId?: number; manual?: boolean; detail?: Detail; detailId?: number };
const statusNames: Record<number, string> = { 0: '草稿', 1: '待检测', 2: '待拣货', 10: '待填写运单', 3: '待执行出库', 4: '已完成', 5: '已取消', 8: '已关闭' };
function statusLabel(order: Sales) { return `${order.partiallyShipped ? '部分发货 / ' : ''}${statusNames[order.status!] || '未知状态'}`; }
const busy = ref(false), error = ref(''), notice = ref(''), code = ref(''), status = ref('');
const rows = ref<Sales[]>([]), page = ref(0), total = ref(0), sales = ref<Sales>();
const lines = ref<Line[]>([]), details = ref<Detail[]>([]), preview = ref<Preview>(), pending = ref<Pending>();
const shipments = ref<MesWmProductSalesApi.Shipment[]>([]);
const shipmentNames: Record<number, string> = { 0: '拣货中', 10: '待填写运单', 20: '待执行出库', 30: '已出库', 40: '已取消' };
const carrier = ref(''), shippingNumber = ref('');
const scanType = ref<'BAG' | 'PALLET' | 'BATCH'>('BAG'), selectedBagIds = ref<number[]>([]);
const manualLine = ref<Line>(), manualStock = ref<MesWmMaterialStockApi.MaterialStock>();
const manualStocks = ref<MesWmMaterialStockApi.MaterialStock[]>([]), stocksLoaded = ref(false);
const manualQuantity = ref<number>();
const storageKey = 'mom-mobile-sales-' + useUserStore().userInfo?.id;
let filters: { code?: string; status?: number } = {};
const picking = computed(() => sales.value?.status === 2 && can('mes:wm-product-sales:stock'));
function clearPending() { pending.value = undefined; sessionStorage.removeItem(storageKey); }
function requestId() {
  if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
  if (!globalThis.crypto?.getRandomValues) throw new Error('当前环境无法生成安全的请求标识');
  const bytes = crypto.getRandomValues(new Uint8Array(16));
  bytes[6] = (bytes[6]! & 15) | 64; bytes[8] = (bytes[8]! & 63) | 128;
  const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
  return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}
async function load(reset = true) {
  if (busy.value || pending.value) return;
  busy.value = true; error.value = '';
@@ -44,51 +61,100 @@
    if (all.length >= data.total || !data.list.length) break;
  }
  const picked = (await Promise.all(all.filter(l => l.id != null).map(l => getProductSalesDetailListByLineId(l.id!)))).flat();
  shipments.value = await getProductSalesShipments(id);
  sales.value = order; lines.value = all; details.value = picked;
  carrier.value = order.carrier || ''; shippingNumber.value = order.shippingNumber || '';
  const current = shipments.value.find(s => s.status === 10);
  carrier.value = current ? current.carrier || '' : order.carrier || '';
  shippingNumber.value = current ? current.shippingNumber || '' : order.shippingNumber || '';
}
async function open(id?: number) {
  if (!id || busy.value || pending.value) return;
  busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined;
  busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined; manualLine.value = undefined; manualStock.value = undefined; manualStocks.value = []; stocksLoaded.value = false;
  try { await readOrder(id); } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
}
function pickedQuantity(lineId?: number) { return details.value.filter(d => d.lineId === lineId).reduce((sum, d) => sum + Number(d.quantity || 0), 0); }
function shippedQuantity(lineId?: number) { const line = lines.value.find(l => l.id === lineId); if (line?.shippedQuantity != null) return Number(line.shippedQuantity); const done = new Set(shipments.value.filter(s => s.status === 30).map(s => s.id)); return details.value.filter(d => d.lineId === lineId && d.shipmentId && done.has(d.shipmentId)).reduce((sum, d) => sum + Number(d.quantity || 0), 0); }
async function identify(raw: string) {
  if (busy.value || !picking.value || pending.value) return;
  busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined;
  busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined; manualLine.value = undefined; manualStock.value = undefined; manualStocks.value = []; stocksLoaded.value = false;
  try {
    selectedBagIds.value = [];
    const data = await previewProductSalesScan(extractCode(raw), scanType.value, sales.value?.id);
    if (data.scanType === 'BAG' && data.codeId && details.value.some(d => d.bagCodeId === data.codeId)) { speakDuplicateScan('outbound'); throw new Error('该袋码已在拣货清单中,请勿重复扫描'); }
    if (!data.codeId || !data.scanQuantity || data.scanQuantity <= 0) throw new Error('无法确认扫码数量,请核对码值');
    const line = lines.value.find(l => l.itemId === data.itemId);
    if (!line) throw new Error('扫码品种不在当前出库单中');
    data.itemName = line.itemName; preview.value = data;
    if (data.scanType === 'BAG') selectedBagIds.value = data.bags?.filter(b => b.selectable).map(b => b.id) || [];
  } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
  } catch (e) { error.value = errorText(e); if (/已扫描出库/.test(error.value)) speakDuplicateScan('outbound'); } finally { busy.value = false; }
}
async function chooseManualLine(line: Line) {
  if (!line.id || !picking.value || busy.value || pending.value) return;
  busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined;
  selectedBagIds.value = []; manualLine.value = line; manualStock.value = undefined; stocksLoaded.value = false;
  try { manualStocks.value = await getPickingStocks(line.id); stocksLoaded.value = true; }
  catch (e) { error.value = errorText(e); } finally { busy.value = false; }
}
async function chooseManualStock(stock: MesWmMaterialStockApi.MaterialStock) {
  if (!stock.id || !manualLine.value || !sales.value?.id || busy.value) return;
  if (!stock.bagCount) { manualStock.value = stock; manualQuantity.value = undefined; preview.value = undefined; return; }
  if (!stock.batchCode) { error.value = '该库存缺少批次号,无法选择袋码'; return; }
  busy.value = true; error.value = ''; preview.value = undefined; selectedBagIds.value = [];
  try {
    const data = await previewProductSalesScan(stock.batchCode, 'BATCH', sales.value.id);
    if (data.itemId !== manualLine.value.itemId) throw new Error('库存批次的品种与出库单行不一致');
    const bags = (data.bags || []).filter(b => b.materialStockId === stock.id);
    if (!bags.length) throw new Error('该库存没有可识别的袋码,请在 PC ç«¯å¤„理无袋码历史库存');
    manualStock.value = stock; data.bags = bags; data.itemName = manualLine.value.itemName;
    data.scanQuantity = bags.filter(b => b.selectable).length; preview.value = data;
  } catch (e) { manualStock.value = undefined; error.value = errorText(e); } finally { busy.value = false; }
}
async function removePendingDetail(detail: Detail) {
  if (!detail.id || detail.shipmentId || !sales.value?.id || !picking.value || busy.value || pending.value || !can('mes:wm-product-sales:delete')) return;
  if (!window.confirm('确认移除这条尚未发货的拣货明细?')) return;
  busy.value = true; error.value = ''; notice.value = '';
  const operation: Pending = { salesId: sales.value.id, before: sales.value.status!, action: 'delete', detailId: detail.id };
  pending.value = operation; sessionStorage.setItem(storageKey, JSON.stringify(operation));
  try { await deleteProductSalesDetail(detail.id); clearPending(); await readOrder(operation.salesId); notice.value = '拣货明细已移除。'; }
  catch (e) { error.value = '操作结果待确认,请先核实:' + errorText(e); } finally { busy.value = false; }
}
async function execute(action: Action) {
  const order = sales.value;
  if (!order?.id || busy.value || pending.value) return;
  const permission = action === 'scan' ? 'stock' : action;
  const permission = action === 'scan' || action === 'manual' ? 'stock' : action === 'close' || action === 'cancelShipment' ? 'cancel' : action;
  if (!can('mes:wm-product-sales:' + permission)) return;
  if (action === 'scan' && (!picking.value || !preview.value)) return;
  if (action === 'scan' && !selectedBagIds.value.length) { error.value = '请选择本次拣货对应的袋码'; return; }
  if (action === 'manual' && (!picking.value || !manualLine.value?.id || !manualStock.value?.id || !manualQuantity.value || manualQuantity.value <= 0 || manualQuantity.value > Number(manualStock.value.availableQuantity ?? manualStock.value.quantity ?? 0))) { error.value = '请输入不超过可用库存的有效数量'; return; }
  if (action === 'stock' && order.status !== 2 || action === 'shipping' && order.status !== 10 || action === 'finish' && order.status !== 3) return;
  if (action === 'shipping' && (!carrier.value.trim() || !shippingNumber.value.trim())) { error.value = '请填写承运商和运输单号'; return; }
  const message = action === 'scan' ? `确认将所选 ${selectedBagIds.value.length} è¢‹åŠ å…¥æœ¬å•æ‹£è´§å¹¶å…³è”è¿½æº¯è¢‹ç ï¼Ÿ` : action === 'stock' ? '确认完成拣货?系统将核对各品种数量。' : action === 'shipping' ? '确认保存运单并进入待执行出库?' : '确认执行出库?本单将扣减库存并标记已完成。';
  const closeReason = action === 'close' ? window.prompt('请输入关闭剩余数量的原因(最多500字)') : undefined;
  if (action === 'close' && closeReason === null) return;
  if (action === 'close' && (!closeReason?.trim() || closeReason.trim().length > 500)) { error.value = '请填写不超过500字的关闭原因'; return; }
  const message = action === 'cancelShipment' ? '确认取消本次发货并释放库存占用?拣货明细会保留供调整。' : action === 'close' ? '确认关闭剩余未发数量?已出库记录将保留。' : action === 'manual' ? `确认按数量拣货 ${manualQuantity.value}?该历史库存没有袋码明细,无法逐袋追溯。` : action === 'scan' ? `确认将所选 ${selectedBagIds.value.length} è¢‹åŠ å…¥æœ¬å•æ‹£è´§å¹¶å…³è”è¿½æº¯è¢‹ç ï¼Ÿ` : action === 'stock' ? '确认完成本次发货拣货?系统将核对累计数量。' : action === 'shipping' ? '确认保存本次运单并进入待执行出库?' : '确认执行本次出库?系统将扣减本次库存。';
  if (!window.confirm(message)) return;
  busy.value = true; error.value = ''; notice.value = '';
  try {
    if (action === 'stock' && !await checkProductSalesQuantity(order.id)) throw new Error('拣货数量与出库数量不一致,请核对每个品种');
    const operation: Pending = { salesId: order.id, action, before: order.status!, preview: action === 'scan' ? { ...preview.value, selectedBagIds: [...selectedBagIds.value], scanQuantity: selectedBagIds.value.length } : undefined, carrier: carrier.value.trim(), shippingNumber: shippingNumber.value.trim() };
    if (action === 'stock' && !await checkProductSalesQuantity(order.id)) throw new Error('请先拣选本次明细,且累计数量不能超过计划数量');
    const operation: Pending = { salesId: order.id, action, before: order.status!, preview: action === 'scan' ? { ...preview.value, selectedBagIds: [...selectedBagIds.value], scanQuantity: selectedBagIds.value.length } : undefined, carrier: carrier.value.trim(), shippingNumber: shippingNumber.value.trim(), lineId: manualStock.value ? manualLine.value?.id : undefined, materialStockId: manualStock.value?.id, manual: !!manualStock.value };
    if (action === 'manual') {
      operation.detail = { salesId: order.id, lineId: manualLine.value!.id, itemId: manualLine.value!.itemId,
        materialStockId: manualStock.value!.id, batchId: manualStock.value!.batchId, batchCode: manualStock.value!.batchCode,
        warehouseId: manualStock.value!.warehouseId,
        quantity: manualQuantity.value, remark: `移动端无袋码拣货 #${requestId()}` };
    }
    pending.value = operation; sessionStorage.setItem(storageKey, JSON.stringify(operation));
    if (action === 'scan') {
      const result = await scanProductSales({ salesId: order.id, scanContent: operation.preview!.scanContent!, scanType: operation.preview!.scanType as 'BAG' | 'PALLET' | 'BATCH', bagIds: operation.preview!.selectedBagIds, quantity: operation.preview!.scanQuantity });
      const result = await scanProductSales({ salesId: order.id, scanContent: operation.preview!.scanContent!, scanType: operation.preview!.scanType as 'BAG' | 'PALLET' | 'BATCH', bagIds: operation.preview!.selectedBagIds, quantity: operation.preview!.scanQuantity, lineId: operation.lineId, materialStockId: operation.materialStockId, manual: operation.manual });
      if (result.alreadyScanned) speakDuplicateScan('outbound');
      notice.value = result.alreadyScanned ? '该码已在拣货清单中,本次未重复添加。' : `已加入拣货:${result.outboundQuantity || operation.preview!.scanQuantity} è¢‹ã€‚`;
    } else if (action === 'stock') { await stockProductSales(order.id); notice.value = '拣货完成,请填写运单。'; }
    } else if (action === 'manual') { await createMobileManualDetail(operation.detail!); notice.value = '数量拣货已加入;该明细无袋码追溯。'; }
    else if (action === 'stock') { await stockProductSales(order.id); notice.value = '拣货完成,请填写运单。'; }
    else if (action === 'shipping') { await shippingProductSales({ id: order.id, carrier: operation.carrier, shippingNumber: operation.shippingNumber }); notice.value = '运单已保存,请核对后执行出库。'; }
    else { await finishProductSales(order.id); notice.value = '出库完成,库存已更新。'; }
    clearPending(); preview.value = undefined;
    else if (action === 'close') { await closeProductSalesRemaining(order.id, closeReason!.trim()); notice.value = '剩余数量已关闭。'; }
    else if (action === 'cancelShipment') { await cancelProductSalesShipment(order.id); notice.value = '本次发货已取消,库存占用已释放。'; }
    else { await finishProductSales(order.id); notice.value = '本次出库完成,库存已更新。'; }
    clearPending(); preview.value = undefined; manualStock.value = undefined; manualLine.value = undefined; manualStocks.value = []; stocksLoaded.value = false;
    await readOrder(order.id);
  } catch (e) { error.value = (pending.value ? '操作结果待确认,请先核实:' : '') + errorText(e); } finally { busy.value = false; }
}
@@ -99,7 +165,7 @@
    const operation = pending.value;
    await readOrder(operation.salesId);
    const requestedIds = operation.preview?.selectedBagIds;
    const found = operation.action === 'scan' ? requestedIds?.length ? requestedIds.every(id => details.value.some(d => d.bagCodeId === id)) : details.value.some(d => operation.preview?.scanType === 'PALLET' ? d.palletCodeId === operation.preview.codeId : d.bagCodeId === operation.preview?.codeId) : sales.value?.status !== operation.before;
    const found = operation.action === 'delete' ? !details.value.some(d => d.id === operation.detailId) : operation.action === 'manual' ? details.value.some(d => d.remark === operation.detail?.remark) : operation.action === 'scan' ? requestedIds?.length ? requestedIds.every(id => details.value.some(d => d.bagCodeId === id)) : details.value.some(d => operation.preview?.scanType === 'PALLET' ? d.palletCodeId === operation.preview.codeId : d.bagCodeId === operation.preview?.codeId) : sales.value?.status !== operation.before;
    if (operation.action === 'scan' && !found && requestedIds?.some(id => details.value.some(d => d.bagCodeId === id))) { error.value = '所选袋码仅部分存在于拣货明细,请联系负责人核对,不能自动重试。'; return; }
    if (found) {
      notice.value = operation.action === 'scan' ? '已核实:该码已在拣货清单中。' : '当前单据状态:' + statusNames[sales.value!.status!] + '。请核对明细和运单后继续。';
@@ -119,15 +185,32 @@
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
  <section v-if="pending" class="m-panel"><h2 class="m-title">出库操作结果待确认</h2><p>单据编号 {{pending.salesId}},请先核实再继续操作。</p><button class="m-primary" :disabled="busy" @click="verify">核实操作结果</button></section>
  <template v-if="sales">
    <button v-if="!pending" class="m-secondary" :disabled="busy" @click="sales = undefined; preview = undefined; load()">返回出库单列表</button>
    <section class="m-panel" style="margin-top:16px"><h2 class="m-title">{{sales.code}}</h2><strong class="m-status">{{statusNames[sales.status!] || '未知状态'}}</strong><dl><div class="m-row"><dt>客户</dt><dd>{{sales.clientName || '—'}}</dd></div><div class="m-row"><dt>收货人</dt><dd>{{sales.contactName || '—'}} {{sales.contactTelephone}}</dd></div><div class="m-row"><dt>收货地址</dt><dd>{{sales.contactAddress || '—'}}</dd></div><div v-if="sales.shippingNumber" class="m-row"><dt>运单</dt><dd>{{sales.carrier}} Â· {{sales.shippingNumber}}</dd></div></dl><button v-if="!pending" class="m-secondary" :disabled="busy" @click="open(sales.id)">刷新单据</button></section>
    <button v-if="!pending" class="m-secondary" :disabled="busy" @click="sales = undefined; preview = undefined; manualLine = undefined; manualStock = undefined; manualStocks = []; stocksLoaded = false; load()">返回出库单列表</button>
    <section class="m-panel" style="margin-top:16px"><h2 class="m-title">{{sales.code}}</h2><strong class="m-status">{{statusLabel(sales)}}</strong><dl><div class="m-row"><dt>客户</dt><dd>{{sales.clientName || '—'}}</dd></div><div class="m-row"><dt>收货人</dt><dd>{{sales.contactName || '—'}} {{sales.contactTelephone}}</dd></div><div class="m-row"><dt>收货地址</dt><dd>{{sales.contactAddress || '—'}}</dd></div><div v-if="sales.shippingNumber" class="m-row"><dt>最近运单</dt><dd>{{sales.carrier}} Â· {{sales.shippingNumber}}</dd></div></dl><button v-if="!pending" class="m-secondary" :disabled="busy" @click="open(sales.id)">刷新单据</button></section>
    <h2 class="m-section-title">出库品种与拣货进度</h2>
    <section v-if="shipments.length" class="m-panel"><h2 class="m-title">发货记录</h2><div v-for="shipment in shipments" :key="shipment.id" class="m-record"><strong>第 {{shipments.indexOf(shipment) + 1}} æ¬¡ Â· {{shipmentNames[shipment.status] || '未知状态'}}</strong><p v-if="shipment.shippingNumber">{{shipment.carrier || '未录入承运商'}} Â· {{shipment.shippingNumber}}</p><p v-if="shipment.outboundTime">出库时间:{{shipment.outboundTime}}</p></div></section>
    <p v-if="!lines.length" class="m-empty">该单暂无品种明细</p>
    <article v-for="line in lines" :key="line.id" class="m-panel"><h3 class="m-title">{{line.itemName || line.itemCode}}</h3><p>计划 {{line.quantity || 0}} {{line.unitMeasureName || '袋'}} Â· å·²æ‹£ {{pickedQuantity(line.id)}} {{line.unitMeasureName || '袋'}}</p><details><summary>查看拣货明细</summary><div v-for="detail in details.filter(d => d.lineId === line.id)" :key="detail.id" class="m-record"><p>{{detail.batchCode || '未录入批次'}} Â· {{detail.quantity}} {{line.unitMeasureName || '袋'}}</p><p class="m-muted">{{detail.warehouseName || '—'}} / {{detail.locationName || '—'}} / {{detail.areaName || '—'}}</p><p class="m-muted">{{detail.bagCodeId ? '袋码编号 ' + detail.bagCodeId : '托盘编号 ' + detail.palletCodeId}}</p><p v-if="detail.remark" class="m-muted" style="overflow-wrap:anywhere">{{detail.remark}}</p></div></details></article>
    <article v-for="line in lines" :key="line.id" class="m-panel"><h3 class="m-title">{{line.itemName || line.itemCode}}</h3><p>计划 {{line.quantity || 0}} Â· å·²å‘ {{shippedQuantity(line.id)}} Â· å·²æ‹£åˆè®¡ {{pickedQuantity(line.id)}} {{line.unitMeasureName || '袋'}}</p><details><summary>查看拣货明细</summary><div v-for="detail in details.filter(d => d.lineId === line.id)" :key="detail.id" class="m-record"><p>{{detail.batchCode || '未录入批次'}} Â· {{detail.quantity}} {{line.unitMeasureName || '袋'}}</p><p class="m-muted">{{detail.warehouseName || '—'}}</p><p class="m-muted">{{detail.bagCodeId ? '袋码编号 ' + detail.bagCodeId : detail.palletCodeId ? '托盘编号 ' + detail.palletCodeId : '无袋码明细'}}</p><p v-if="detail.remark" class="m-muted" style="overflow-wrap:anywhere">{{formatPickingRemark(detail.remark)}}</p><button v-if="picking && !detail.shipmentId && !pending && can('mes:wm-product-sales:delete')" class="m-secondary" :disabled="busy || !!preview" @click="removePendingDetail(detail)">移除未发明细</button></div></details><button v-if="picking && !pending" class="m-secondary" :disabled="busy || !!preview" @click="chooseManualLine(line)">手工选择库存袋码</button></article>
    <section v-if="manualLine && !pending" class="m-panel">
      <h2 class="m-title">选择 {{manualLine.itemName || manualLine.itemCode}} çš„库存</h2>
      <p v-if="stocksLoaded && !manualStocks.length" class="m-empty">没有可选的合格库存</p>
      <button v-for="stock in manualStocks" :key="stock.id" class="m-order-button" :disabled="busy || !!preview" @click="chooseManualStock(stock)">
        <strong>批次 {{stock.batchCode || '未录入'}}</strong><span>{{stock.warehouseName}}</span><small>可用 {{stock.availableQuantity ?? stock.quantity ?? 0}} Â· åœ¨åº“袋码 {{stock.bagCount ?? 0}}</small>
      </button>
      <div v-if="manualStock && !manualStock.bagCount && !preview" class="m-record">
        <p>该库存暂无在库袋码明细,按数量拣货无法逐袋追溯。</p>
        <label class="m-field"><span>本次拣货数量</span><input v-model.number="manualQuantity" type="number" min="0.000001" step="any" :max="manualStock.availableQuantity ?? manualStock.quantity ?? 0" :disabled="busy" /></label>
        <button class="m-primary" :disabled="busy || !manualQuantity" @click="execute('manual')">确认数量拣货</button>
      </div>
      <button class="m-detail-toggle" :disabled="busy || !!preview" @click="manualLine = undefined; manualStocks = []; stocksLoaded = false">关闭库存选择</button>
    </section>
    <template v-if="!pending">
      <template v-if="picking"><h2 class="m-section-title">扫码拣货</h2><label class="m-field"><span>拣货扫码类型</span><select v-model="scanType" :disabled="busy" @change="preview = undefined; selectedBagIds = []"><option value="BAG">袋码</option><option value="PALLET">托盘码</option><option value="BATCH">生产批次码</option></select></label><p class="m-muted">支持业务码与追溯码。托盘、批次识别后选择实际拣货袋码,数量按所选袋数计入。</p><Scanner :disabled="busy" @code="identify" /><section v-if="preview" class="m-panel"><h3 class="m-title">核对拣货对象</h3><p>{{preview.itemName}}</p><p style="overflow-wrap:anywhere">{{preview.scanContent}}</p><p>批次 {{preview.batchCode}} Â· å¯æ‹£ {{preview.scanQuantity}} è¢‹</p><PickingBags v-if="preview.scanType !== 'BAG'" v-model="selectedBagIds" :bags="preview.bags || []" :disabled="busy" /><p v-else>袋码 {{preview.bags?.[0]?.code}}</p><p role="status">本次拣货 {{selectedBagIds.length}} è¢‹</p><button class="m-primary" :disabled="busy || !selectedBagIds.length" @click="execute('scan')">确认加入拣货</button><button class="m-detail-toggle" :disabled="busy" @click="preview = undefined">取消本次扫码</button></section></template>
      <template v-if="picking"><h2 class="m-section-title">扫码拣货</h2><label class="m-field"><span>拣货扫码类型</span><select v-model="scanType" :disabled="busy" @change="preview = undefined; selectedBagIds = []"><option value="BAG">袋码</option><option value="PALLET">托盘码</option><option value="BATCH">生产批次码</option></select></label><p class="m-muted">支持追溯码。托盘、批次识别后选择实际拣货袋码,数量按所选袋数计入。</p><Scanner voice-operation="outbound" duplicate-voice :disabled="busy" @code="identify" /><section v-if="preview" class="m-panel"><h3 class="m-title">核对拣货对象</h3><p>{{preview.itemName}}</p><p style="overflow-wrap:anywhere">{{preview.scanContent}}</p><p>批次 {{preview.batchCode}} Â· å¯æ‹£ {{preview.scanQuantity}} è¢‹</p><PickingBags v-if="preview.scanType !== 'BAG'" v-model="selectedBagIds" :bags="preview.bags || []" :disabled="busy" /><p v-else>袋码 {{preview.bags?.[0]?.code}}</p><p role="status">本次拣货 {{selectedBagIds.length}} è¢‹</p><button class="m-primary" :disabled="busy || !selectedBagIds.length" @click="execute('scan')">确认加入拣货</button><button class="m-detail-toggle" :disabled="busy" @click="preview = undefined">取消本次扫码</button></section></template>
      <form v-if="sales.status === 10 && can('mes:wm-product-sales:shipping')" class="m-panel" @submit.prevent="execute('shipping')"><h2 class="m-title">填写运单</h2><label class="m-field"><span>承运商</span><input v-model="carrier" :disabled="busy" maxlength="100" required /></label><label class="m-field"><span>运输单号</span><input v-model="shippingNumber" :disabled="busy" maxlength="100" required /></label><button class="m-primary" :disabled="busy">保存运单</button></form>
      <div v-if="!preview" class="m-action-bar"><button v-if="picking" class="m-primary" :disabled="busy || !lines.length" @click="execute('stock')">完成拣货并核对数量</button><button v-if="sales.status === 3 && can('mes:wm-product-sales:finish')" class="m-primary" :disabled="busy" @click="execute('finish')">确认执行出库</button></div>
      <button v-if="picking && shipments.some(s => s.status === 30) && can('mes:wm-product-sales:cancel')" class="m-secondary" :disabled="busy || !!preview" @click="execute('close')">关闭剩余数量</button>
      <button v-if="(sales.status === 10 || sales.status === 3) && can('mes:wm-product-sales:cancel')" class="m-secondary" :disabled="busy" @click="execute('cancelShipment')">取消本次发货</button>
      <p v-if="sales.status === 8" class="m-warning">剩余数量已关闭:{{sales.closeReason || '未记录原因'}}</p>
      <p v-if="sales.status === 0 || sales.status === 1" class="m-warning">{{sales.status === 0 ? '该单为草稿,请在 PC ç«¯ç»´æŠ¤å“ç§å¹¶æäº¤åŽè¿›è¡Œç§»åŠ¨æ‹£è´§ã€‚' : '该单待检测,检测完成后才能拣货。'}}</p>
      <p v-if="sales.status === 4" class="m-success">该单已完成出库。</p>
    </template>
@@ -135,7 +218,7 @@
  <template v-else-if="!pending">
    <form class="m-panel" @submit.prevent="load()"><label class="m-field"><span>出库单号</span><input v-model="code" :disabled="busy" placeholder="输入出库单号" /></label><label class="m-field"><span>单据状态</span><select v-model="status" :disabled="busy"><option value="">全部状态</option><option v-for="(name, value) in statusNames" :key="value" :value="String(value)">{{name}}</option></select></label><button class="m-secondary" :disabled="busy">{{busy ? '正在查询…' : '查询出库单'}}</button></form>
    <p class="m-muted" role="status">共 {{total}} å¼ å‡ºåº“单</p><p v-if="!rows.length && !busy && !error" class="m-empty">暂无匹配的出库单</p>
    <button v-for="row in rows" :key="row.id" class="m-order-button" :disabled="busy" @click="open(row.id)"><strong>{{row.code}}</strong><span>{{statusNames[row.status!] || '未知状态'}}</span><small>{{row.clientName || '未录入客户'}}</small></button>
    <button v-for="row in rows" :key="row.id" class="m-order-button" :disabled="busy" @click="open(row.id)"><strong>{{row.code}}</strong><span>{{statusLabel(row)}}</span><small>{{row.clientName || '未录入客户'}}</small></button>
    <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">{{busy ? '正在加载…' : '加载更多'}}</button>
  </template>
</template>
src/views/mobile/scanner.vue
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
import Icon from '#/mobile/icon.vue';
const props=defineProps<{disabled?:boolean;continuous?:boolean}>();
import { speakDuplicateScan, type ScanVoiceOperation } from '#/mobile/scanVoice';
const props=defineProps<{disabled?:boolean;continuous?:boolean;duplicateVoice?:boolean;voiceOperation?:ScanVoiceOperation}>();
const emit=defineEmits<{code:[value:string]}>();
const input=ref(''),error=ref(''),active=ref(false),starting=ref(false),video=ref<HTMLVideoElement>(),codeInput=ref<HTMLInputElement>();
let stream:MediaStream|undefined,frame=0,generation=0,last=0;
async function testVoice(){error.value='';if(!await speakDuplicateScan(props.voiceOperation))error.value='语音播放失败,请检查媒体音量、浏览器声音权限和网络。';}
function stop(){generation++;active.value=false;starting.value=false;cancelAnimationFrame(frame);stream?.getTracks().forEach(t=>t.stop());stream=undefined;if(video.value)video.value.srcObject=null;}
function accept(value:string){if(props.disabled)return;stop();input.value=props.continuous?'':value;emit('code',value);}
watch([()=>props.disabled,()=>props.continuous],async ([disabled,continuous])=>{if(!disabled&&continuous){await nextTick();codeInput.value?.focus();}});
@@ -51,6 +53,8 @@
<div class="m-panel">
  <button v-if="!active" type="button" class="m-secondary" :disabled="disabled||starting" @click="start"><Icon name="scan"/>{{starting?'正在请求相机权限…':'打开摄像头扫码'}}</button>
  <template v-else><video ref="video" class="m-camera" muted playsinline aria-label="二维码取景"/><button type="button" class="m-secondary" @click="stop">关闭摄像头</button></template>
  <button v-if="duplicateVoice" type="button" class="m-secondary" @click="testVoice">测试重复扫码语音</button>
  <p v-if="duplicateVoice" class="m-muted">首次使用请点击测试并调高媒体音量。</p>
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
  <form @submit.prevent="accept(input)">
    <label class="m-field" style="margin-top:16px"><span>码值 / æº¯æºé“¾æŽ¥</span><input ref="codeInput" v-model="input" autocomplete="off" :disabled="disabled" placeholder="手动输入或使用扫码设备" maxlength="2048" /></label>
src/views/mobile/stock.vue
@@ -2,32 +2,29 @@
import { onMounted,ref } from 'vue';
import { requestClient } from '#/api/request';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { getMaterialStockBagDetails, type MaterialStockBagDetail } from '#/api/mes/wm/materialstock';
import { errorText } from '#/mobile/api';
import { getWarehouseSimpleList, type MesWmWarehouseApi } from '#/api/mes/wm/warehouse';
import { getWarehouseLocationSimpleList, type MesWmWarehouseLocationApi } from '#/api/mes/wm/warehouse/location';
import { getWarehouseAreaSimpleList, type MesWmWarehouseAreaApi } from '#/api/mes/wm/warehouse/area';
import Picker from './picker.vue';
defineOptions({name:'MobileStock'});
const positionLoading=ref(false);
let positionRequest=0;
const warehouses=ref<MesWmWarehouseApi.Warehouse[]>([]),locations=ref<MesWmWarehouseLocationApi.WarehouseLocation[]>([]),areas=ref<MesWmWarehouseAreaApi.WarehouseArea[]>([]);
const warehouseId=ref<number>(),locationId=ref<number>(),areaId=ref<number>();
async function changeWarehouse(){
 const ticket=++positionRequest,id=warehouseId.value;locationId.value=undefined;areaId.value=undefined;locations.value=[];areas.value=[];positionLoading.value=!!id;
 try{if(id){const list=await getWarehouseLocationSimpleList(id);if(ticket===positionRequest)locations.value=list;}}
 catch(e){if(ticket===positionRequest)error.value=errorText(e);}finally{if(ticket===positionRequest)positionLoading.value=false;}
}
async function changeLocation(){
 const ticket=++positionRequest,id=locationId.value;areaId.value=undefined;areas.value=[];positionLoading.value=!!id;
 try{if(id){const list=await getWarehouseAreaSimpleList(id);if(ticket===positionRequest)areas.value=list;}}
 catch(e){if(ticket===positionRequest)error.value=errorText(e);}finally{if(ticket===positionRequest)positionLoading.value=false;}
}
const keyword=ref(''),batch=ref(''),page=ref(1),busy=ref(false),error=ref(''),total=ref(0),rows=ref<MesWmMaterialStockApi.MaterialStock[]>([]),expanded=ref<number>();
type Filters={itemName?:string;batchCode?:string;warehouseId?:number;locationId?:number;areaId?:number};
const warehouses=ref<MesWmWarehouseApi.Warehouse[]>([]);
const warehouseId=ref<number>();
const keyword=ref(''),batch=ref(''),includeZero=ref(false),page=ref(1),busy=ref(false),error=ref(''),total=ref(0),rows=ref<MesWmMaterialStockApi.MaterialStock[]>([]),expanded=ref<number>();
type Filters={itemName?:string;batchCode?:string;warehouseId?:number;includeZero?:boolean};
let applied:Filters={};
const bags=ref<MaterialStockBagDetail[]>([]), bagTotal=ref(0), bagPage=ref(0), bagBusy=ref(false), bagStockId=ref<number>();
async function loadBags(id:number, reset=false){
  if(bagBusy.value)return;
  bagBusy.value=true; error.value='';
  try{
    const next=reset?1:bagPage.value+1;
    const result=await getMaterialStockBagDetails({materialStockId:id,includeHistory:true,pageNo:next,pageSize:20});
    bags.value=reset?result.list:[...bags.value,...result.list]; bagTotal.value=result.total; bagPage.value=next; bagStockId.value=id;
  }catch(e){error.value=errorText(e);}finally{bagBusy.value=false;}
}
async function load(reset=false){
  if(busy.value)return;busy.value=true;error.value='';const nextPage=reset?1:page.value+1;
  const filters=reset?{itemName:keyword.value.trim()||undefined,batchCode:batch.value.trim()||undefined,warehouseId:warehouseId.value,locationId:locationId.value,areaId:areaId.value}:applied;
  const filters=reset?{itemName:keyword.value.trim()||undefined,batchCode:batch.value.trim()||undefined,warehouseId:warehouseId.value,includeZero:includeZero.value}:applied;
  try{
    const r=await requestClient.get<{list:MesWmMaterialStockApi.MaterialStock[];total:number}>('/mes/wm/material-stock/page',{params:{pageNo:nextPage,pageSize:20,...filters}});
    rows.value=reset?r.list:[...rows.value,...r.list];total.value=r.total;page.value=nextPage;
@@ -40,10 +37,9 @@
<form class="m-panel" @submit.prevent="load(true)">
<label class="m-field"><span>品种名称</span><input v-model="keyword" placeholder="搜索品种"/></label>
<label class="m-field"><span>批次号</span><input v-model="batch" placeholder="可选,输入批次号"/></label>
<details><summary class="m-link">按仓库 / åº“位筛选</summary>
<Picker v-model="warehouseId" label="仓库" :options="warehouses" :disabled="busy" allow-empty placeholder="全部仓库" @update:model-value="changeWarehouse"/>
<Picker v-model="locationId" label="库区" :options="locations" :disabled="busy||!warehouseId" :loading="positionLoading" allow-empty placeholder="全部库区" @update:model-value="changeLocation"/>
<Picker v-model="areaId" label="库位" :options="areas" :disabled="busy||!locationId" :loading="positionLoading" allow-empty placeholder="全部库位"/>
<label><input v-model="includeZero" type="checkbox" :disabled="busy" /> åŒ…含零库存记录</label>
<details><summary class="m-link">按仓库筛选</summary>
<Picker v-model="warehouseId" label="仓库" :options="warehouses" :disabled="busy" allow-empty placeholder="全部仓库"/>
</details>
<button class="m-primary" :disabled="busy">{{busy?'正在查询…':'查询库存'}}</button>
</form>
@@ -51,9 +47,16 @@
<p v-if="!rows.length&&!busy" class="m-empty">暂无匹配库存</p>
<article v-for="row in rows" :key="row.id" class="m-panel">
<h2 class="m-title">{{row.itemName}}</h2><p><strong>{{row.availableQuantity??row.quantity??0}}</strong> {{row.unitMeasureName}} <span class="m-muted">{{row.availableQuantity!=null?'可用':'在库'}}</span></p>
<p class="m-muted">{{row.warehouseName}} / {{row.locationName}} / {{row.areaName}}</p><p class="m-muted">批次:{{row.batchCode||'未录入'}}</p>
<p class="m-muted">{{row.warehouseName}}</p><p class="m-muted">批次:{{row.batchCode||'未录入'}}</p>
<button class="m-detail-toggle" :aria-expanded="expanded===row.id" @click="expanded=expanded===row.id?undefined:row.id"> {{expanded===row.id?'收起明细':'查看数量明细'}}</button>
<dl v-if="expanded===row.id"><div class="m-row"><dt>在库数量</dt><dd>{{row.quantity}} {{row.unitMeasureName}}</dd></div><div class="m-row"><dt>冻结数量</dt><dd>{{row.frozenQuantity??0}}</dd></div><div class="m-row"><dt>占用数量</dt><dd>{{row.reservedQuantity??0}}</dd></div><div class="m-row"><dt>入库时间</dt><dd>{{row.receiptTime||'未录入'}}</dd></div></dl>
<template v-if="expanded===row.id && row.bagDetailSupported">
<button class="m-secondary" :disabled="bagBusy" @click="loadBags(row.id!,true)">查看袋码状态</button>
<template v-if="bagStockId===row.id">
<p v-if="!bags.length" class="m-muted">暂无袋码明细</p>
<div v-for="bag in bags" :key="bag.id" class="m-record"><p style="overflow-wrap:anywhere">{{bag.bagCode}}</p><p>{{bag.status===1?(bag.pickingStatus==='RESERVED'?'已占用':bag.pickingStatus==='PICKED'?'已拣选':'在库'):bag.status===2?(bag.outboundBizType===119?'销售已出库':'已出库'):bag.status===3?'已报废':'未知状态'}}<span v-if="bag.outboundTime"> Â· {{bag.outboundTime}}</span></p></div>
<button v-if="bags.length<bagTotal" class="m-secondary" :disabled="bagBusy" @click="loadBags(row.id!)">加载更多袋码</button>
</template></template>
</article>
<button v-if="rows.length<total" class="m-secondary" :disabled="busy" @click="load()">加载更多</button>
</template>
src/views/mobile/stocktaking.vue
@@ -157,7 +157,7 @@
    <article v-for="line in lines" :key="line.id" class="m-panel">
      <h3 class="m-title">{{ line.itemName || line.itemCode }}</h3>
      <p>账面数量 {{ line.quantity ?? 0 }} <span class="m-muted">批次 {{ line.batchCode || '未录入' }}</span></p>
      <p class="m-muted">{{ [line.warehouseName, line.locationName, line.areaName].filter(Boolean).join(' / ') || '—' }}</p>
      <p class="m-muted">{{ line.warehouseName || '—' }}</p>
      <p v-if="line.takingQuantity != null" class="m-muted">已盘 {{ line.takingQuantity }},差异 {{ line.differenceQuantity ?? 0 }}</p>
    </article>
    <h2 class="m-section-title">扫码盘点</h2>
@@ -168,7 +168,7 @@
      <dl>
        <div class="m-row"><dt>物料</dt><dd>{{ trace.data.itemName || matchedLine?.itemName || '未录入' }}</dd></div>
        <div class="m-row"><dt>批次</dt><dd>{{ trace.data.batchCode || matchedLine?.batchCode || '未绑定' }}</dd></div>
        <div class="m-row"><dt>库位</dt><dd>{{ [matchedLine?.warehouseName, matchedLine?.locationName, matchedLine?.areaName].filter(Boolean).join(' / ') || '不在盘点范围' }}</dd></div>
        <div class="m-row"><dt>仓库</dt><dd>{{ matchedLine?.warehouseName || '不在盘点范围' }}</dd></div>
        <div class="m-row"><dt>账面数量</dt><dd>{{ matchedLine?.quantity ?? '—' }}</dd></div>
        <div class="m-row"><dt>码值</dt><dd style="overflow-wrap: anywhere">{{ trace.code }}</dd></div>
      </dl>
src/views/mobile/transfer.vue
@@ -122,8 +122,6 @@
      quantity: quantity.value,
      batchId: matched.batchId,
      fromWarehouseId: matched.warehouseId,
      fromLocationId: matched.locationId,
      fromAreaId: matched.areaId,
      remark: `扫码来源 ${trace.value?.code ?? ''}`,
    });
    notice.value = '已添加移库明细。';
@@ -170,7 +168,7 @@
      <dl>
        <div class="m-row"><dt>物料</dt><dd>{{ trace.data.itemName || '未录入' }}</dd></div>
        <div class="m-row"><dt>批次</dt><dd>{{ stock?.batchCode || trace.data.batchCode || '未绑定' }}</dd></div>
        <div class="m-row"><dt>移出位置</dt><dd>{{ [stock?.warehouseName, stock?.locationName, stock?.areaName].filter(Boolean).join(' / ') || '—' }}</dd></div>
        <div class="m-row"><dt>移出仓库</dt><dd>{{ stock?.warehouseName || '—' }}</dd></div>
        <div class="m-row"><dt>库存数量</dt><dd>{{ stock?.quantity ?? '—' }}</dd></div>
        <div class="m-row"><dt>码值</dt><dd style="overflow-wrap: anywhere">{{ trace.code }}</dd></div>
      </dl>
src/views/system/appearance/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,209 @@
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import {
  getSystemAppearance,
  saveSystemAppearance,
  uploadAppearanceImage,
} from '#/api/system/appearance';
import { applyAppearance } from '#/utils/system-appearance';
defineOptions({ name: 'SystemAppearance' });
interface ImageItem {
  blobId: null | number;
  url: string;
}
const logo = reactive<ImageItem>({ blobId: null, url: '' });
const loginBackground = reactive<ImageItem>({ blobId: null, url: '' });
const saving = ref(false);
async function loadAppearance() {
  const data = await getSystemAppearance();
  logo.blobId = data?.logoBlobId ?? null;
  logo.url = data?.logoUrl ?? '';
  loginBackground.blobId = data?.loginBackgroundBlobId ?? null;
  loginBackground.url = data?.loginBackgroundUrl ?? '';
}
async function handleUpload(file: File, target: ImageItem) {
  if (!file.type.startsWith('image/')) {
    message.error('请上传图片文件');
    return false;
  }
  const hideLoading = message.loading({ content: '上传中...', duration: 0 });
  try {
    const blobs = await uploadAppearanceImage(file);
    const blob = blobs?.[0];
    if (!blob) {
      message.error('上传失败');
      return false;
    }
    target.blobId = blob.id;
    target.url = blob.previewURL || blob.downloadURL || '';
    message.success('上传成功,点击保存后生效');
  } catch {
    message.error('上传失败');
  } finally {
    hideLoading();
  }
  return false;
}
function beforeLogoUpload(file: File) {
  return handleUpload(file, logo);
}
function beforeBackgroundUpload(file: File) {
  return handleUpload(file, loginBackground);
}
function handleClear(target: ImageItem) {
  target.blobId = null;
  target.url = '';
}
async function handleSave() {
  saving.value = true;
  try {
    await saveSystemAppearance({
      logoBlobId: logo.blobId,
      loginBackgroundBlobId: loginBackground.blobId,
    });
    applyAppearance({
      logoUrl: logo.url || undefined,
      loginBackgroundUrl: loginBackground.url || undefined,
    });
    message.success('保存成功,外观已生效');
  } finally {
    saving.value = false;
  }
}
onMounted(loadAppearance);
</script>
<template>
  <Page auto-content-height>
    <div class="appearance-page">
      <a-card title="登录页背景图" class="appearance-card">
        <div class="appearance-preview appearance-preview--background">
          <img v-if="loginBackground.url" :src="loginBackground.url" alt="登录页背景图" />
          <div v-else class="appearance-empty">未设置,使用系统默认背景</div>
        </div>
        <div class="appearance-actions">
          <a-upload
            accept="image/*"
            :before-upload="beforeBackgroundUpload"
            :show-upload-list="false"
          >
            <a-button>上传背景图</a-button>
          </a-upload>
          <a-button
            v-if="loginBackground.url"
            danger
            @click="handleClear(loginBackground)"
          >
            æ¸…除
          </a-button>
          <span class="appearance-tip">支持 jpg、png、webp ç­‰å¸¸è§„图片格式</span>
        </div>
      </a-card>
      <a-card title="企业 Logo" class="appearance-card">
        <div class="appearance-preview appearance-preview--logo">
          <img v-if="logo.url" :src="logo.url" alt="企业 Logo" />
          <div v-else class="appearance-empty">未设置,使用系统默认 Logo</div>
        </div>
        <div class="appearance-actions">
          <a-upload
            accept="image/*"
            :before-upload="beforeLogoUpload"
            :show-upload-list="false"
          >
            <a-button>上传 Logo</a-button>
          </a-upload>
          <a-button v-if="logo.url" danger @click="handleClear(logo)">
            æ¸…除
          </a-button>
          <span class="appearance-tip">Logo åŒæ—¶ç”¨äºŽç™»å½•页与首页侧边栏</span>
        </div>
      </a-card>
      <div class="appearance-footer">
        <a-button type="primary" :loading="saving" @click="handleSave">
          ä¿å­˜
        </a-button>
      </div>
    </div>
  </Page>
</template>
<style scoped>
.appearance-page {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 16px;
  overflow-y: auto;
}
.appearance-card {
  max-width: 720px;
}
.appearance-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  border: 1px dashed rgb(203 213 225);
  border-radius: 8px;
  background: rgb(248 250 252);
}
.appearance-preview--background {
  height: 220px;
}
.appearance-preview--logo {
  height: 140px;
}
.appearance-preview img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.appearance-preview--logo img {
  object-fit: contain;
  padding: 12px;
}
.appearance-empty {
  color: rgb(148 163 184);
  font-size: 14px;
}
.appearance-actions {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 12px;
}
.appearance-tip {
  color: rgb(148 163 184);
  font-size: 13px;
}
.appearance-footer {
  max-width: 720px;
}
</style>
src/views/wls/barcode/data.ts
@@ -34,11 +34,7 @@
import { WmMaterialStockSelect } from '../materialstock/components';
import { WmPackageSelect } from '#/views/wls/packages/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '../warehouse/components';
import { WmWarehouseSelect } from '../warehouse/components';
/** ä¸šåŠ¡å¯¹è±¡é€‰ä¸­åŽå›žå¡«ä¸šåŠ¡ç¼–ç ã€ä¸šåŠ¡åç§°ã€æ¡ç å†…å®¹ */
async function syncBizDetail(formApi: undefined | VbenFormApi, item: any) {
@@ -107,11 +103,16 @@
            bizCode: undefined,
            bizName: undefined,
            content: undefined,
            locationWarehouseId: undefined,
            areaWarehouseId: undefined,
            areaLocationId: undefined,
          }),
        options: getDictOptions(DICT_TYPE.MES_WM_BARCODE_BIZ_TYPE, 'number'),
        // ä¸å†æ”¯æŒæŒ‰åº“区/库位生成条码
        options: getDictOptions(
          DICT_TYPE.MES_WM_BARCODE_BIZ_TYPE,
          'number',
        ).filter(
          (item) =>
            item.value !== BarcodeBizTypeEnum.LOCATION &&
            item.value !== BarcodeBizTypeEnum.AREA,
        ),
        placeholder: '请选择业务类型',
      },
      rules: 'required',
@@ -127,95 +128,6 @@
      dependencies: {
        triggerFields: ['bizType'],
        show: (values) => values.bizType === BarcodeBizTypeEnum.WAREHOUSE,
      },
      rules: 'required',
    },
    {
      fieldName: 'locationWarehouseId',
      label: '库区·仓库',
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        onChange: () =>
          formApi?.setValues({
            bizId: undefined,
            bizCode: undefined,
            bizName: undefined,
            content: undefined,
          }),
        placeholder: '请选择仓库',
      },
      dependencies: {
        triggerFields: ['bizType'],
        show: (values) => values.bizType === BarcodeBizTypeEnum.LOCATION,
      },
    },
    {
      fieldName: 'bizId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      dependencies: {
        triggerFields: ['bizType', 'locationWarehouseId'],
        show: (values) => values.bizType === BarcodeBizTypeEnum.LOCATION,
        componentProps: (values) => ({
          onChange: (item: any) => syncBizDetail(formApi, item),
          placeholder: '请选择库区',
          warehouseId: values.locationWarehouseId,
        }),
      },
      rules: 'required',
    },
    {
      fieldName: 'areaWarehouseId',
      label: '库位·仓库',
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        onChange: () =>
          formApi?.setValues({
            areaLocationId: undefined,
            bizId: undefined,
            bizCode: undefined,
            bizName: undefined,
            content: undefined,
          }),
        placeholder: '请选择仓库',
      },
      dependencies: {
        triggerFields: ['bizType'],
        show: (values) => values.bizType === BarcodeBizTypeEnum.AREA,
      },
    },
    {
      fieldName: 'areaLocationId',
      label: '库位·库区',
      component: markRaw(WmWarehouseLocationSelect),
      dependencies: {
        triggerFields: ['bizType', 'areaWarehouseId'],
        show: (values) => values.bizType === BarcodeBizTypeEnum.AREA,
        componentProps: (values) => ({
          onChange: () =>
            formApi?.setValues({
              bizId: undefined,
              bizCode: undefined,
              bizName: undefined,
              content: undefined,
            }),
          placeholder: '请选择库区',
          warehouseId: values.areaWarehouseId,
        }),
      },
    },
    {
      fieldName: 'bizId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      dependencies: {
        triggerFields: ['bizType', 'areaLocationId'],
        show: (values) => values.bizType === BarcodeBizTypeEnum.AREA,
        componentProps: (values) => ({
          locationId: values.areaLocationId,
          onChange: (item: any) => syncBizDetail(formApi, item),
          placeholder: '请选择库位',
        }),
      },
      rules: 'required',
    },
src/views/wls/barcode/modules/form.vue
@@ -4,14 +4,12 @@
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum, CommonStatusEnum } from '@vben/constants';
import { CommonStatusEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createBarcode, getBarcode, updateBarcode } from '#/api/mes/wm/barcode';
import { getWarehouseArea } from '#/api/mes/wm/warehouse/area';
import { getWarehouseLocation } from '#/api/mes/wm/warehouse/location';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
@@ -38,25 +36,6 @@
  schema: [],
  showDefaultActions: false,
});
/** åŠ è½½çº§è”é€‰æ‹©å™¨çš„æ•°æ®ï¼ˆç¼–è¾‘æ—¶å›žå¡«ä¸Šçº§ä»“åº“/库区) */
async function loadCascadeData(data: MesWmBarcodeApi.Barcode) {
  if (!data.bizType || !data.bizId) {
    return;
  }
  if (data.bizType === BarcodeBizTypeEnum.LOCATION) {
    const location = await getWarehouseLocation(data.bizId);
    if (location?.warehouseId) {
      await formApi.setFieldValue('locationWarehouseId', location.warehouseId);
    }
  } else if (data.bizType === BarcodeBizTypeEnum.AREA) {
    const area = await getWarehouseArea(data.bizId);
    await formApi.setValues({
      areaWarehouseId: area?.warehouseId,
      areaLocationId: area?.locationId,
    });
  }
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
@@ -96,7 +75,6 @@
      formData.value = await getBarcode(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
      await loadCascadeData(formData.value);
    } finally {
      modalApi.unlock();
    }
src/views/wls/itemreceipt/data.ts
@@ -24,11 +24,7 @@
  WmArrivalNoticeLineSelect,
  WmArrivalNoticeSelect,
} from '#/views/wls/arrivalnotice/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'finish' | 'stock' | 'update';
@@ -440,16 +436,6 @@
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区名称',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位名称',
      minWidth: 100,
    },
    {
      field: 'quantity',
      title: '数量',
      width: 100,
@@ -483,52 +469,9 @@
      label: '入库仓库',
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        // åˆ‡æ¢ä»“库后清空库区和库位
        onChange: async () => {
          await formApi?.setValues({
            areaId: undefined,
            locationId: undefined,
          });
        },
        placeholder: '请选择仓库',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        placeholder: '请选择库区',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          // åˆ‡æ¢åº“区后清空库位
          onChange: async () => {
            await formApi?.setFieldValue('areaId', undefined);
          },
          placeholder: '请选择库区',
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        placeholder: '请选择库位',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values) => ({
          locationId: values.locationId,
          placeholder: '请选择库位',
        }),
      },
    },
    {
      fieldName: 'quantity',
src/views/wls/materialstock/components/move-modal.vue
@@ -11,11 +11,7 @@
import { z } from '#/adapter/form';
import { useVbenForm } from '#/adapter/form';
import { moveMaterialStock } from '#/api/mes/wm/materialstock';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
defineOptions({ name: 'WmStockMoveModal' });
@@ -50,18 +46,6 @@
      componentProps: { disabled: true },
    },
    {
      fieldName: 'locationName',
      label: '当前库区',
      component: 'Input',
      componentProps: { disabled: true },
    },
    {
      fieldName: 'areaName',
      label: '当前库位',
      component: 'Input',
      componentProps: { disabled: true },
    },
    {
      fieldName: 'quantity',
      label: '当前数量',
      component: 'InputNumber',
@@ -73,52 +57,6 @@
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        placeholder: '请选择目标仓库',
        onChange: async () => {
          await formApi.setFieldValue('toLocationId', undefined);
          await formApi.setFieldValue('toAreaId', undefined);
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'toLocationId',
      label: '目标库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: { placeholder: '请选择目标库区' },
      dependencies: {
        triggerFields: ['toWarehouseId'],
        componentProps: (values) => ({
          warehouseId: values.toWarehouseId,
          placeholder: '请选择目标库区',
          onChange: async () => {
            await formApi.setFieldValue('toAreaId', undefined);
          },
        }),
        trigger: (values, api) => {
          if (values.toLocationId !== undefined) {
            void api.setFieldValue('toLocationId', undefined);
            void api.setFieldValue('toAreaId', undefined);
          }
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'toAreaId',
      label: '目标库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: { placeholder: '请选择目标库位' },
      dependencies: {
        triggerFields: ['toLocationId'],
        componentProps: (values) => ({
          locationId: values.toLocationId,
          placeholder: '请选择目标库位',
        }),
        trigger: (values, api) => {
          if (values.toAreaId !== undefined) {
            void api.setFieldValue('toAreaId', undefined);
          }
        },
      },
      rules: 'selectRequired',
    },
@@ -179,8 +117,8 @@
      return;
    }
    if (row.warehouseId === values.toWarehouseId && row.locationId === values.toLocationId && row.areaId === values.toAreaId) {
      message.error('目标库位与当前库位相同,不允许移库');
    if (row.warehouseId === values.toWarehouseId) {
      message.error('目标仓库与当前仓库相同,不允许移库');
      return;
    }
@@ -190,8 +128,6 @@
        materialStockId: row.id,
        quantity: moveQuantity,
        remark: values.remark,
        toAreaId: values.toAreaId,
        toLocationId: values.toLocationId,
        toWarehouseId: values.toWarehouseId,
      });
      message.success('移库成功');
@@ -217,11 +153,9 @@
    }
    await formApi.setValues({
      areaName: data.areaName,
      batchCode: data.batchCode,
      itemCode: data.itemCode,
      itemName: data.itemName,
      locationName: data.locationName,
      quantity: data.quantity,
      warehouseName: data.warehouseName,
    });
src/views/wls/materialstock/components/select.vue
@@ -132,8 +132,6 @@
          <div>批次:{{ selectedItem.batchCode || '-' }}</div>
          <div>数量:{{ selectedItem.quantity ?? '-' }}</div>
          <div>仓库:{{ selectedItem.warehouseName || '-' }}</div>
          <div>库区:{{ selectedItem.locationName || '-' }}</div>
          <div>库位:{{ selectedItem.areaName || '-' }}</div>
        </div>
      </template>
      <Input
src/views/wls/materialstock/components/stock-adjust-modal.vue
@@ -22,11 +22,7 @@
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import { ErpSupplierSelect } from '#/views/erp/purchase/supplier/components';
import BatchConfigDisplay from './batch-config-display.vue';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
const emit = defineEmits<{
  success: [];
@@ -35,8 +31,6 @@
// æ“ä½œç±»åž‹ï¼š1=入库,2=出库
const operateType = ref<1 | 2>(1);
const selectedItem = ref<MdmItemApi.Item | null>(null);
const selectedWarehouse = ref<any>(null);
const selectedLocation = ref<any>(null);
// æ˜¯å¦å¯ç”¨æ‰¹æ¬¡ç®¡ç†
const batchEnabled = ref(false);
@@ -102,19 +96,6 @@
  await formApi.setFieldValue('manualBatchEntry', false);
}
/** ä»“库选择变化 */
async function handleWarehouseChange(item: any) {
  selectedWarehouse.value = item;
  await formApi.setFieldValue('locationId', undefined);
  await formApi.setFieldValue('areaId', undefined);
}
/** åº“区选择变化 */
async function handleLocationChange(item: any) {
  selectedLocation.value = item;
  await formApi.setFieldValue('areaId', undefined);
}
/** æ‰‹åŠ¨å¡«å†™æ‰¹æ¬¡å·å˜åŒ– */
async function handleManualBatchEntryChange(value: boolean) {
  manualBatchEntry.value = value;
@@ -165,54 +146,6 @@
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        placeholder: '请选择仓库',
        onChange: handleWarehouseChange,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        placeholder: '请选择库区',
        warehouseId: selectedWarehouse.value?.id,
        onChange: handleLocationChange,
      },
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values: any) => ({
          placeholder: '请选择库区',
          warehouseId: values.warehouseId,
          onChange: handleLocationChange,
        }),
        trigger: (values: any, formApi: any) => {
          if (values.locationId !== undefined) {
            void formApi.setFieldValue('locationId', undefined);
            void formApi.setFieldValue('areaId', undefined);
          }
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        placeholder: '请选择库位',
        locationId: selectedLocation.value?.id,
      },
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values: any) => ({
          placeholder: '请选择库位',
          locationId: values.locationId,
        }),
        trigger: (values: any, formApi: any) => {
          if (values.areaId !== undefined) {
            void formApi.setFieldValue('areaId', undefined);
          }
        },
      },
      rules: 'selectRequired',
    },
@@ -398,8 +331,6 @@
  onOpenChange: async (isOpen: boolean) => {
    if (!isOpen) {
      selectedItem.value = null;
      selectedWarehouse.value = null;
      selectedLocation.value = null;
      batchEnabled.value = false;
      operateType.value = 1;
      modalTitle.value = '库存调整';
@@ -426,8 +357,6 @@
      await formApi.setValues({
        itemId: data.itemId,
        warehouseId: data.warehouseId,
        locationId: data.locationId,
        areaId: data.areaId,
      });
      if (data.itemId) {
        const itemDetail = await getItem(data.itemId);
@@ -491,8 +420,6 @@
      const params: MaterialStockAdjustInParams = {
        itemId: values.itemId,
        warehouseId: values.warehouseId,
        locationId: values.locationId,
        areaId: values.areaId,
        batchCode: values.batchCode,
        produceDate: values.produceDate,
        expireDate: values.expireDate,
@@ -511,8 +438,6 @@
      const params: MaterialStockAdjustOutParams = {
        itemId: values.itemId,
        warehouseId: values.warehouseId,
        locationId: values.locationId,
        areaId: values.areaId,
        batchCode: values.batchCode,
        vendorId: values.vendorId,
        quantity: values.quantity,
src/views/wls/materialstock/components/stock-bag-modal.vue
@@ -28,13 +28,17 @@
          },
        },
      },
      { field: 'status', title: '袋码状态', width: 110,
        formatter: ({ row }) => row.status === 1 ? (row.pickingStatus === 'RESERVED' ? '已占用' : row.pickingStatus === 'PICKED' ? '已拣选' : '在库') : row.status === 2 ? (row.outboundBizType === 119 ? '销售已出库' : '已出库') : row.status === 3 ? '已报废' : '未知状态' },
      { field: 'quantity', title: '数量', width: 80 },
      { field: 'outboundTime', title: '出库时间', width: 180 },
      { field: 'inboundTime', title: '入库时间', width: 180 },
    ],
    proxyConfig: {
      ajax: {
        query: async ({ page }) => getMaterialStockBagDetails({
          materialStockId: stockId.value,
          includeHistory: true,
          pageNo: page.currentPage,
          pageSize: page.pageSize,
        }),
src/views/wls/materialstock/data.ts
@@ -9,11 +9,7 @@
import { MdItemSelect } from '#/views/mes/md/item/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '../warehouse/components';
import { WmWarehouseSelect } from '../warehouse/components';
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
@@ -44,24 +40,6 @@
      },
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          warehouseId: values.warehouseId,
          placeholder: '请选择库区',
        }),
        // ä»“库切换时清空库区,避免旧库区条件残留
        trigger: (values, formApi) => {
          if (values.locationId !== undefined) {
            void formApi.setFieldValue('locationId', undefined);
          }
        },
      },
    },
    {
      fieldName: 'stockState',
      label: '库存状态',
      component: 'Select',
@@ -70,10 +48,16 @@
        options: [
          { label: '暂存库(待质检)', value: 10 },
          { label: '合格库(正式库存)', value: 20 },
          { label: '已出库(销售扣减)', value: 30 },
          { label: '已出库', value: 30 },
        ],
        placeholder: '请选择库存状态',
      },
    },
    {
      fieldName: 'includeZero',
      label: '零库存记录',
      component: 'Select',
      componentProps: { allowClear: true, placeholder: '默认不显示', options: [{ label: '包含零库存', value: true }] },
    },
    {
      fieldName: 'frozen',
@@ -113,7 +97,12 @@
    },
    {
      field: 'specification',
      title: '规格型号',
      title: '规格',
      minWidth: 120,
    },
    {
      field: 'itemTypeName',
      title: '类别',
      minWidth: 120,
    },
    {
@@ -172,11 +161,6 @@
      },
    },
    {
      field: 'unitMeasureName',
      title: '单位',
      width: 80,
    },
    {
      field: 'batchCode',
      title: '批次号',
      minWidth: 140,
@@ -191,17 +175,6 @@
      field: 'warehouseName',
      title: '仓库',
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位',
      minWidth: 100,
      slots: { default: 'areaName' },
    },
    {
      field: 'receiptTime',
@@ -271,42 +244,6 @@
        placeholder: '请选择仓库',
      },
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          warehouseId: values.warehouseId,
          placeholder: '请选择库区',
        }),
        // ä»“库切换时清空库区
        trigger: (values, formApi) => {
          if (values.locationId !== undefined) {
            void formApi.setFieldValue('locationId', undefined);
          }
        },
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      dependencies: {
        triggerFields: ['warehouseId', 'locationId'],
        componentProps: (values) => ({
          locationId: values.locationId,
          placeholder: '请选择库位',
        }),
        // ä»“库或库区切换时清空库位
        trigger: (values, formApi) => {
          if (values.areaId !== undefined) {
            void formApi.setFieldValue('areaId', undefined);
          }
        },
      },
    },
  ];
}
@@ -331,13 +268,13 @@
    },
    {
      field: 'specification',
      title: '规格型号',
      title: '规格',
      minWidth: 120,
    },
    {
      field: 'unitMeasureName',
      title: '单位',
      width: 80,
      field: 'itemTypeName',
      title: '类别',
      minWidth: 120,
    },
    {
      field: 'batchCode',
@@ -347,16 +284,6 @@
    {
      field: 'warehouseName',
      title: '仓库',
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位',
      minWidth: 100,
    },
    {
src/views/wls/materialstock/index.vue
@@ -18,7 +18,6 @@
import { $t } from '#/locales';
import { MdItemTypeTree } from '#/views/mes/md/item/type/components';
import QrTraceModal from '#/views/mes/pro/components/qr-trace-modal.vue';
import AreaForm from '#/views/wls/warehouse/area/modules/form.vue';
import {
  WmStockAdjustModal,
  WmStockBagModal,
@@ -26,11 +25,6 @@
} from './components';
import { useGridColumns, useGridFormSchema } from './data';
const [AreaModal, areaModalApi] = useVbenModal({
  connectedComponent: AreaForm,
  destroyOnClose: true,
});
const [StockAdjustModal, stockAdjustModalApi] = useVbenModal({
  connectedComponent: WmStockAdjustModal,
@@ -76,14 +70,6 @@
function handleTypeNodeClick(row: undefined | { id?: number }) {
  searchItemTypeId.value = row?.id;
  handleRefresh();
}
/** æ‰“开库位详情弹窗 */
function handleOpenAreaDetail(row: MesWmMaterialStockApi.MaterialStock) {
  if (!row.areaId) {
    return;
  }
  areaModalApi.setData({ formType: 'detail', id: row.areaId }).open();
}
/** æ‰“开库存调整弹窗 */
@@ -169,7 +155,6 @@
<template>
  <Page auto-content-height>
    <AreaModal />
    <QrTraceQueryModal />
    <StockBagModal />
    <StockAdjustModal @success="handleStockAdjustSuccess" />
@@ -212,18 +197,6 @@
              @click="handleOpenBatchDetail(row)"
            >
              {{ row.batchCode }}
            </Button>
            <span v-else>-</span>
          </template>
          <template #areaName="{ row }">
            <Button
              v-if="row.areaId"
              :title="row.areaName"
              size="small"
              type="link"
              @click="handleOpenAreaDetail(row)"
            >
              {{ row.areaName }}
            </Button>
            <span v-else>-</span>
          </template>
src/views/wls/miscissue/data.ts
@@ -14,11 +14,7 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getRangePickerDefaultProps } from '#/utils';
import { WmMaterialStockSelect } from '#/views/wls/materialstock/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'finish' | 'update';
@@ -302,16 +298,6 @@
      title: '仓库',
      minWidth: 120,
    },
    {
      field: 'locationName',
      title: '库区',
      minWidth: 120,
    },
    {
      field: 'areaName',
      title: '库位',
      minWidth: 120,
    },
    ...(editable
      ? [
          {
@@ -349,13 +335,11 @@
      label: '库存物资',
      component: markRaw(WmMaterialStockSelect),
      componentProps: {
        // é€‰æ‹©åº“存物资后,自动回填物料、批次号、仓库位置和最大可出库数量
        // é€‰æ‹©åº“存物资后,自动回填物料、批次号、仓库和最大可出库数量
        onChange: async (stock?: MesWmMaterialStockApi.MaterialStock) => {
          await formApi?.setValues({
            areaId: stock?.areaId,
            batchCode: stock?.batchCode,
            itemId: stock?.itemId,
            locationId: stock?.locationId,
            quantityMax: stock?.quantity,
            warehouseId: stock?.warehouseId,
          });
@@ -406,36 +390,6 @@
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        disabled: true,
      },
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          disabled: true,
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values) => ({
          disabled: true,
          locationId: values.locationId,
        }),
      },
    },
    {
src/views/wls/miscissue/modules/line-list.vue
@@ -62,10 +62,8 @@
    lineFormModalApi
      .setData({
        defaults: {
          areaId: stock.areaId,
          batchCode: stock.batchCode,
          itemId: stock.itemId,
          locationId: stock.locationId,
          materialStockId: stock.id,
          quantity:
            quantity && stock.quantity
src/views/wls/miscreceipt/data.ts
@@ -13,11 +13,7 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getRangePickerDefaultProps } from '#/utils';
import { MdItemSelect } from '#/views/mes/md/item/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'finish' | 'update';
@@ -301,16 +297,6 @@
      title: '仓库',
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位',
      minWidth: 100,
    },
    ...(editable
      ? [
          {
@@ -361,52 +347,9 @@
      label: '仓库',
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        // åˆ‡æ¢ä»“库后清空库区和库位
        onChange: async () => {
          await formApi?.setValues({
            areaId: undefined,
            locationId: undefined,
          });
        },
        placeholder: '请选择仓库',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        placeholder: '请选择库区',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          // åˆ‡æ¢åº“区后清空库位
          onChange: async () => {
            await formApi?.setFieldValue('areaId', undefined);
          },
          placeholder: '请选择库区',
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        placeholder: '请选择库位',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values) => ({
          locationId: values.locationId,
          placeholder: '请选择库位',
        }),
      },
    },
    {
      fieldName: 'remark',
src/views/wls/outsourceissue/data.ts
@@ -23,11 +23,7 @@
import { MdVendorSelect } from '#/views/mes/md/vendor/components';
import { ProWorkOrderSelect } from '#/views/mes/pro/workorder/components';
import { WmMaterialStockSelect } from '#/views/wls/materialstock/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'finish' | 'stock' | 'update';
@@ -342,16 +338,6 @@
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区名称',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位名称',
      minWidth: 100,
    },
    {
      field: 'quantity',
      title: '数量',
      width: 100,
@@ -385,13 +371,11 @@
      label: '库存记录',
      component: markRaw(WmMaterialStockSelect),
      componentProps: {
        // é€‰æ‹©åº“存记录后,自动回填仓库/库区/库位/批次/数量
        // é€‰æ‹©åº“存记录后,自动回填仓库/批次/数量
        onChange: async (stock?: MesWmMaterialStockApi.MaterialStock) => {
          await formApi?.setValues({
            areaId: stock?.areaId,
            batchCode: stock?.batchCode,
            batchId: stock?.batchId,
            locationId: stock?.locationId,
            quantity: stock?.quantity,
            quantityMax: stock?.quantity,
            warehouseId: stock?.warehouseId,
@@ -443,36 +427,6 @@
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        disabled: true,
      },
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          disabled: true,
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values) => ({
          disabled: true,
          locationId: values.locationId,
        }),
      },
    },
    {
src/views/wls/outsourcereceipt/data.ts
@@ -23,11 +23,7 @@
import { MdItemSelect } from '#/views/mes/md/item/components';
import { MdVendorSelect } from '#/views/mes/md/vendor/components';
import { ProWorkOrderSelect } from '#/views/mes/pro/workorder/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'finish' | 'stock' | 'update';
@@ -415,16 +411,6 @@
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区名称',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位名称',
      minWidth: 100,
    },
    {
      field: 'quantity',
      title: '数量',
      width: 100,
@@ -467,52 +453,9 @@
      label: '入库仓库',
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        // åˆ‡æ¢ä»“库后清空库区和库位
        onChange: async () => {
          await formApi?.setValues({
            areaId: undefined,
            locationId: undefined,
          });
        },
        placeholder: '请选择仓库',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        placeholder: '请选择库区',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          // åˆ‡æ¢åº“区后清空库位
          onChange: async () => {
            await formApi?.setFieldValue('areaId', undefined);
          },
          placeholder: '请选择库区',
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        placeholder: '请选择库位',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values) => ({
          locationId: values.locationId,
          placeholder: '请选择库位',
        }),
      },
    },
    {
      fieldName: 'quantity',
src/views/wls/productissue/data.ts
@@ -22,11 +22,7 @@
import { MdWorkstationSelect } from '#/views/mes/md/workstation/components';
import { ProWorkOrderSelect } from '#/views/mes/pro/workorder/components';
import { WmMaterialStockSelect } from '#/views/wls/materialstock/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'finish' | 'stock' | 'update';
@@ -331,16 +327,6 @@
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区名称',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位名称',
      minWidth: 100,
    },
    {
      field: 'quantity',
      title: '数量',
      width: 100,
@@ -382,13 +368,11 @@
      label: '库存记录',
      component: markRaw(WmMaterialStockSelect),
      componentProps: {
        // é€‰æ‹©åº“存记录后,自动回填仓库/库区/库位/批次/数量
        // é€‰æ‹©åº“存记录后,自动回填仓库/批次/数量
        onChange: async (stock?: MesWmMaterialStockApi.MaterialStock) => {
          await formApi?.setValues({
            areaId: stock?.areaId,
            batchCode: stock?.batchCode,
            batchId: stock?.batchId,
            locationId: stock?.locationId,
            quantity: stock?.quantity,
            quantityMax: stock?.quantity,
            warehouseId: stock?.warehouseId,
@@ -431,36 +415,6 @@
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        disabled: true,
      },
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          disabled: true,
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values) => ({
          disabled: true,
          locationId: values.locationId,
        }),
      },
    },
    {
src/views/wls/productreceipt/data.ts
@@ -20,11 +20,7 @@
import { MdItemSelect } from '#/views/mes/md/item/components';
import { ProWorkOrderSelect } from '#/views/mes/pro/workorder/components';
import { WmMaterialStockSelect } from '#/views/wls/materialstock/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'finish' | 'stock' | 'update';
@@ -346,16 +342,6 @@
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区名称',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位名称',
      minWidth: 100,
    },
    {
      field: 'quantity',
      title: '数量',
      width: 100,
@@ -389,52 +375,9 @@
      label: '入库仓库',
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        // åˆ‡æ¢ä»“库后清空库区和库位
        onChange: async () => {
          await formApi?.setValues({
            areaId: undefined,
            locationId: undefined,
          });
        },
        placeholder: '请选择仓库',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        placeholder: '请选择库区',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          // åˆ‡æ¢åº“区后清空库位
          onChange: async () => {
            await formApi?.setFieldValue('areaId', undefined);
          },
          placeholder: '请选择库区',
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        placeholder: '请选择库位',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values) => ({
          locationId: values.locationId,
          placeholder: '请选择库位',
        }),
      },
    },
    {
      fieldName: 'quantity',
src/views/wls/productsales/data.ts
@@ -28,11 +28,7 @@
  WmSalesNoticeLineSelect,
  WmSalesNoticeSelect,
} from '#/views/wls/salesnotice/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType =
@@ -270,10 +266,7 @@
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(
          DICT_TYPE.MES_WM_PRODUCT_SALES_STATUS,
          'number',
        ),
        options: [...getDictOptions(DICT_TYPE.MES_WM_PRODUCT_SALES_STATUS, 'number'), { label: '已关闭', value: 8 }],
        placeholder: '请选择单据状态',
      },
    },
@@ -334,10 +327,7 @@
      field: 'status',
      title: '单据状态',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_WM_PRODUCT_SALES_STATUS },
      },
      formatter: ({ cellValue, row }) => `${row.partiallyShipped ? '部分发货 / ' : ''}${({ 0: '草稿', 1: '待检测', 2: '待拣货', 3: '待执行出库', 4: '已完成', 5: '已取消', 8: '已关闭', 10: '待填写运单' } as Record<number, string>)[cellValue] || '未知状态'}`,
    },
    {
      title: '操作',
@@ -384,6 +374,8 @@
      title: '出库数量',
      width: 100,
    },
    { field: 'shippedQuantity', title: '已出库', width: 100 },
    { field: 'remainingQuantity', title: '剩余数量', width: 100 },
    {
      field: 'batchCode',
      title: '批次号',
@@ -508,11 +500,16 @@
      minWidth: 130,
    },
    {
      field: 'shipmentId',
      title: '发货记录',
      minWidth: 120,
      formatter: ({ row }) => row.shipmentId ? `发货 #${row.shipmentId}` : '待分配 / åŽ†å²æ˜Žç»†',
    },
    {
      field: 'bagCodeId',
      title: '扫码来源',
      minWidth: 110,
      formatter: ({ row }) =>
         row.bagCodeId ? `袋码 #${row.bagCodeId}${row.palletCodeId ? ` / æ‰˜ç›˜ #${row.palletCodeId}` : ''}` : row.palletCodeId ? `托盘码 #${row.palletCodeId}` : '手工拣货',
      slots: { default: 'scanSource' },
    },
    {
      field: 'warehouseName',
@@ -520,21 +517,11 @@
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区名称',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位名称',
      minWidth: 100,
    },
    {
      field: 'quantity',
      title: '数量',
      width: 100,
    },
    { field: 'remark', title: '袋码与扫码来源', minWidth: 240 },
    { field: 'remark', title: '袋码与扫码来源', minWidth: 240, slots: { default: 'pickingRemark' } },
    ...(stockable
      ? [
          {
@@ -548,8 +535,30 @@
  ];
}
/** æ‰«ç æ¥æºå±•示文案 */
export function formatScanSource(
  row: MesWmProductSalesDetailApi.ProductSalesDetail,
) {
  if (row.bagCodeId) {
    return `袋码 #${row.bagCodeId}${row.palletCodeId ? ` / æ‰˜ç›˜ #${row.palletCodeId}` : ''}`;
  }
  return row.palletCodeId ? `托盘码 #${row.palletCodeId}` : '手工拣货';
}
export function formatPickingRemark(value?: string) {
  const names: Record<string, string> = { BAG: '袋码', PALLET: '托盘码', BATCH: '批次码' };
  return (value || '')
    .replace(/(扫码类型\s+)(BAG|PALLET|BATCH)(\s*[;;]\s*)来源码(?=\s)/g,
      (_match, prefix: string, type: string, separator: string) => `${prefix}${type}${separator}来源${names[type]}`)
    .replace(/扫码类型\s+(BAG|PALLET|BATCH)(?=\s*[;;]|$)/g,
    (_match, type: string) => `扫码类型 ${names[type]}`);
}
/** æ‹£è´§æ˜Žç»†æ–°å¢ž/修改的表单 */
export function useDetailFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
export function useDetailFormSchema(
  formApi?: VbenFormApi,
  onStockChange?: (stock?: MesWmMaterialStockApi.MaterialStock) => void,
): VbenFormSchema[] {
  return [
    {
      fieldName: 'quantityMax',
@@ -582,17 +591,16 @@
      componentProps: {
        // åªä»Žåˆæ ¼ä»“库(type=20)选择可拣货库存
        warehouseType: MesWmWarehouseTypeEnum.QUALIFIED,
        // é€‰æ‹©åº“存记录后,自动回填仓库/库区/库位/批次/数量
        // é€‰æ‹©åº“存记录后,自动回填仓库/批次/数量
        onChange: async (stock?: MesWmMaterialStockApi.MaterialStock) => {
          await formApi?.setValues({
            areaId: stock?.areaId,
            batchCode: stock?.batchCode,
            batchId: stock?.batchId,
            locationId: stock?.locationId,
            quantity: stock?.quantity,
            quantityMax: stock?.quantity,
            warehouseId: stock?.warehouseId,
          });
          onStockChange?.(stock);
        },
      },
      rules: 'selectRequired',
@@ -632,36 +640,6 @@
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        disabled: true,
      },
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          disabled: true,
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values) => ({
          disabled: true,
          locationId: values.locationId,
        }),
      },
    },
    {
src/views/wls/productsales/index.vue
@@ -11,6 +11,8 @@
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  cancelProductSales,
  cancelProductSalesShipment,
  closeProductSalesRemaining,
  deleteProductSales,
  exportProductSales,
  getProductSalesPage,
@@ -80,6 +82,19 @@
  await cancelProductSales(row.id!);
  message.success('取消成功');
  handleRefresh();
}
async function handleCloseRemaining(row: MesWmProductSalesApi.ProductSales) {
  const reason = window.prompt('请输入关闭剩余数量的原因(最多500字)');
  if (reason === null) return;
  if (!reason.trim() || reason.trim().length > 500) { message.warning('请填写不超过500字的关闭原因'); return; }
  await closeProductSalesRemaining(row.id!, reason.trim());
  message.success('剩余数量已关闭'); handleRefresh();
}
async function handleCancelShipment(row: MesWmProductSalesApi.ProductSales) {
  await cancelProductSalesShipment(row.id!);
  message.success('本次发货已取消,库存占用已释放'); handleRefresh();
}
/** å¯¼å‡ºè¡¨æ ¼ */
@@ -193,6 +208,20 @@
              onClick: handleFinish.bind(null, row),
            },
            {
              label: '取消本次发货',
              type: 'link',
              auth: ['mes:wm-product-sales:cancel'],
              ifShow: row.status === MesWmProductSalesStatusEnum.SHIPPING || row.status === MesWmProductSalesStatusEnum.APPROVED,
              popConfirm: { title: '取消本次发货并释放库存占用?拣货明细将保留供调整。', confirm: handleCancelShipment.bind(null, row) },
            },
            {
              label: '关闭剩余',
              type: 'link',
              auth: ['mes:wm-product-sales:cancel'],
              ifShow: row.status === MesWmProductSalesStatusEnum.APPROVING,
              onClick: handleCloseRemaining.bind(null, row),
            },
            {
              label: '取消',
              type: 'link',
              danger: true,
src/views/wls/productsales/modules/detail-form.vue
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { MesWmProductSalesDetailApi } from '#/api/mes/wm/productsales/detail';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -16,11 +16,33 @@
import { $t } from '#/locales';
import { useDetailFormSchema } from '../data';
import { previewProductSalesScan, scanProductSales, type MesWmProductSalesApi } from '#/api/mes/wm/productsales';
import PickingBags from './picking-bags.vue';
const emit = defineEmits<{ success: [lineId: number] }>();
const formData = ref<MesWmProductSalesDetailApi.ProductSalesDetail>();
const salesId = ref<number>(); // æ‰€å±žå‡ºåº“单编号
const lineId = ref<number>(); // æ‰€å±žå‡ºåº“单行编号
const bagPreview = ref<MesWmProductSalesApi.ProductSalesScanResponse>();
const selectedBagIds = ref<number[]>([]);
const previewStockId = ref<number>();
const previewLoading = ref(false);
async function loadBags(showWarning = true) {
  if (previewLoading.value || !salesId.value) return;
  const values = await formApi.getValues();
  if (!values.materialStockId || !values.batchCode) {
    if (showWarning) message.warning('请先选择带批次号的库存');
    return;
  }
  previewLoading.value = true; bagPreview.value = undefined; selectedBagIds.value = []; previewStockId.value = undefined;
  try {
    const preview = await previewProductSalesScan(values.batchCode, 'BATCH', salesId.value);
    previewStockId.value = values.materialStockId;
    bagPreview.value = { ...preview, bags: (preview.bags || []).filter(b => b.materialStockId === values.materialStockId) };
    if (showWarning && !bagPreview.value.bags?.length) message.warning('该库存没有可识别的袋码;历史无袋码库存可按数量拣货');
  } finally { previewLoading.value = false; }
}
const getTitle = computed(() => {
  return formData.value?.id
@@ -42,6 +64,13 @@
  wrapperClass: 'grid-cols-3',
});
// è¢‹ç å‹¾é€‰åŽåŒæ­¥é¡¶éƒ¨æ•°é‡ï¼Œç›´è§‚反映实际拣出袋数
watch(selectedBagIds, (ids) => {
  if (bagPreview.value?.bags?.length && ids.length) {
    void formApi.setFieldValue('quantity', ids.length);
  }
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
@@ -55,9 +84,28 @@
    data.salesId = salesId.value;
    data.lineId = lineId.value;
    try {
      await (formData.value?.id
        ? updateProductSalesDetail({ ...data, id: formData.value.id })
        : createProductSalesDetail(data));
      if (!formData.value?.id) {
        // æœªåŠ è½½è¢‹ç æˆ–åº“å­˜/批次已变更时,先加载一次;有袋码的库存不允许按数量拣货
        if (!bagPreview.value || previewStockId.value !== data.materialStockId
          || bagPreview.value.batchCode !== data.batchCode) {
          try { await loadBags(false); } catch { /* é¢„览失败时按数量拣货,由后端最终校验 */ }
        }
        const preview = bagPreview.value;
        const bags = preview?.bags ?? [];
        if (preview && bags.length) {
          if (previewStockId.value !== data.materialStockId || preview.batchCode !== data.batchCode) {
            message.warning('库存或批次已变更,请重新加载袋码'); return;
          }
          if (!selectedBagIds.value.length) { message.warning('请选择实际拣出的袋码'); return; }
          await scanProductSales({ salesId: salesId.value!, lineId: lineId.value!, materialStockId: data.materialStockId,
            manual: true, scanType: 'BATCH', scanContent: preview.scanContent!,
            bagIds: selectedBagIds.value, quantity: selectedBagIds.value.length });
        } else {
          await createProductSalesDetail(data);
        }
      } else {
        await updateProductSalesDetail({ ...data, id: formData.value.id });
      }
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      emit('success', lineId.value!);
@@ -69,9 +117,15 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      bagPreview.value = undefined; selectedBagIds.value = []; previewStockId.value = undefined;
      return;
    }
    formApi.setState({ schema: useDetailFormSchema(formApi) });
    formApi.setState({
      schema: useDetailFormSchema(formApi, (stock) => {
        // é€‰æ‹©åº“存后自动加载袋码,避免有袋码的库存误走数量拣货
        if (stock && !formData.value?.id) { void loadBags(false); }
      }),
    });
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      batchId?: number;
@@ -104,7 +158,26 @@
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
  <Modal :title="getTitle" class="w-4/5">
    <Form class="mx-4" />
    <div v-if="!formData?.id" class="mx-4 mt-2">
      <a-alert
        type="info"
        show-icon
        class="mb-3"
        message="请先选择带批次号的库存,再加载并勾选实际拣出的袋码;无袋码的历史库存可按数量拣货。"
      />
      <div class="mb-2 flex flex-wrap items-center gap-2">
        <a-button type="primary" :loading="previewLoading" @click="loadBags()">
          {{ previewLoading ? '正在加载袋码…' : '加载库存袋码' }}
        </a-button>
        <a-tag v-if="bagPreview" color="blue">可识别 {{ bagPreview.bags?.length ?? 0 }} è¢‹</a-tag>
        <a-tag v-if="selectedBagIds.length" color="green">已选 {{ selectedBagIds.length }} è¢‹</a-tag>
        <span v-if="bagPreview && !bagPreview.bags?.length" class="text-sm text-gray-500">
          è¯¥åº“存没有可识别的袋码,可按数量拣货。
        </span>
      </div>
      <PickingBags v-if="bagPreview?.bags?.length" v-model="selectedBagIds" :bags="bagPreview.bags" :disabled="previewLoading" />
    </div>
  </Modal>
</template>
src/views/wls/productsales/modules/detail-list.vue
@@ -12,7 +12,11 @@
import { deleteProductSalesDetail } from '#/api/mes/wm/productsales/detail';
import { $t } from '#/locales';
import { useDetailGridColumns } from '../data';
import {
  formatPickingRemark,
  formatScanSource,
  useDetailGridColumns,
} from '../data';
const props = defineProps<{
  details: MesWmProductSalesDetailApi.ProductSalesDetail[];
@@ -76,6 +80,19 @@
<template>
  <div class="px-4 py-2">
    <Grid>
      <template #scanSource="{ row }">
        <a-tooltip :title="formatScanSource(row)" placement="topLeft">
          <span class="block truncate">{{ formatScanSource(row) }}</span>
        </a-tooltip>
      </template>
      <template #pickingRemark="{ row }">
        <a-tooltip
          :title="formatPickingRemark(row.remark) || undefined"
          placement="topLeft"
        >
          <span class="block truncate">{{ formatPickingRemark(row.remark) }}</span>
        </a-tooltip>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
@@ -83,13 +100,14 @@
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              ifShow: !row.bagCodeId,
              ifShow: !row.bagCodeId && !row.shipmentId,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              ifShow: !row.shipmentId,
              icon: ACTION_ICON.DELETE,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [
src/views/wls/productsales/modules/form.vue
@@ -16,6 +16,7 @@
  createProductSales,
  finishProductSales,
  getProductSales,
  getProductSalesShipments,
  previewProductSalesScan,
  scanProductSales,
  shippingProductSales,
@@ -33,6 +34,8 @@
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const formData = ref<MesWmProductSalesApi.ProductSales>();
const shipments = ref<MesWmProductSalesApi.Shipment[]>([]);
const shipmentStatus: Record<number, string> = { 0: '拣货中', 10: '待填写运单', 20: '待执行出库', 30: '已出库', 40: '已取消' };
const scanContent = ref('');
const scanType = ref<'' | 'BAG' | 'BATCH' | 'PALLET'>('');
const scanPreview = ref<MesWmProductSalesApi.ProductSalesScanResponse>();
@@ -156,18 +159,17 @@
  }
}
/** æ‰§è¡Œæ‹£è´§ï¼šå‡ºåº“数量与拣货数量不一致时二次确认 */
/** æ‰§è¡Œæ‹£è´§ï¼šæ ¸å¯¹å‡ºåº“数量与拣货数量 */
async function handleStock() {
  if (!formData.value?.id) {
    return;
  }
  const quantityMatch = await checkProductSalesQuantity(formData.value.id);
  if (!quantityMatch) {
    try {
      await confirm('出库数量与拣货数量不一致,确认执行拣货?');
    } catch {
      return;
    }
    message.warning(
      '本轮还没有待发货的拣货明细:请先在物料信息中「拣货」或扫码加入袋码,且累计数量不能超过计划数量',
    );
    return;
  }
  modalApi.lock();
  try {
@@ -260,6 +262,7 @@
    scanContent.value = '';
    if (!isOpen) {
      formData.value = undefined;
      shipments.value = [];
      originalSnapshot.value = '';
      return;
    }
@@ -273,8 +276,13 @@
      modalApi.lock();
      try {
        formData.value = await getProductSales(data.id);
        shipments.value = await getProductSalesShipments(data.id);
        // è®¾ç½®åˆ° values
        await formApi.setValues(formData.value);
        if (formType.value === 'shipping') {
          const current = shipments.value.find(s => s.status === 10);
          if (current) await formApi.setValues({ carrier: current.carrier || '', shippingNumber: current.shippingNumber || '' });
        }
      } finally {
        modalApi.unlock();
      }
@@ -289,6 +297,14 @@
    <Form class="mx-4" />
    <!-- éžæ–°å»ºæ¨¡å¼å±•示物料信息 -->
    <template v-if="formData?.id">
      <div v-if="shipments.length" class="mx-4 mb-3">
        <h3 class="font-semibold">发货记录</h3>
        <div v-for="(shipment, index) in shipments" :key="shipment.id" class="mt-2 border-b pb-2">
          ç¬¬ {{index + 1}} æ¬¡ Â· {{shipmentStatus[shipment.status] || '未知状态'}}
          <span v-if="shipment.shippingNumber"> Â· {{shipment.carrier || '未录入承运商'}} / {{shipment.shippingNumber}}</span>
          <span v-if="shipment.outboundTime"> Â· å‡ºåº“æ—¶é—´ {{shipment.outboundTime}}</span>
        </div>
      </div>
      <Divider>物料信息</Divider>
      <div class="mx-4">
        <div v-if="isStock" class="mb-3 flex items-center gap-2 px-4">
src/views/wls/productsales/modules/picking-bags.vue
@@ -1,50 +1,183 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { MesWmProductSalesApi } from '#/api/mes/wm/productsales';
const props = defineProps<{ bags: MesWmProductSalesApi.PickingBag[]; modelValue: number[]; disabled?: boolean }>();
import { computed, ref, watch } from 'vue';
defineOptions({ name: 'PickingBags' });
const props = withDefaults(
  defineProps<{
    bags: MesWmProductSalesApi.PickingBag[];
    modelValue: number[];
    disabled?: boolean;
  }>(),
  { disabled: false },
);
const emit = defineEmits<{ 'update:modelValue': [ids: number[]] }>();
const search = ref(''), desired = ref<number>(), limit = ref(50), selectionError = ref('');
const eligible = computed(() => props.bags.filter(b => b.selectable));
const filtered = computed(() => props.bags.filter(b => !search.value || b.code.includes(search.value.trim()) || b.businessCode?.includes(search.value.trim())));
watch(search, () => { limit.value = 50; });
watch(() => props.modelValue, ids => { desired.value = ids.length || undefined; });
function choose(bag: MesWmProductSalesApi.PickingBag, checked: boolean) {
  selectionError.value = '';
  emit('update:modelValue', checked ? [...props.modelValue, bag.id] : props.modelValue.filter(id => id !== bag.id));
}
const search = ref('');
const desired = ref<number>();
const selectionError = ref('');
const VISIBLE_SELECTED = 8;
const eligible = computed(() => props.bags.filter((bag) => bag.selectable));
const unavailableCount = computed(() => props.bags.length - eligible.value.length);
const filtered = computed(() => {
  const keyword = search.value.trim();
  if (!keyword) {
    return props.bags;
  }
  return props.bags.filter(
    (bag) =>
      bag.code.includes(keyword) ||
      (bag.businessCode ?? '').includes(keyword) ||
      (bag.unavailableReason ?? '').includes(keyword),
  );
});
const selectedBags = computed(() => {
  const selected = new Set(props.modelValue);
  return props.bags.filter((bag) => selected.has(bag.id));
});
const visibleSelected = computed(() => selectedBags.value.slice(0, VISIBLE_SELECTED));
watch(
  () => props.modelValue,
  (ids) => {
    desired.value = ids.length || undefined;
  },
  { immediate: true },
);
const rowSelection = computed(() => ({
  selectedRowKeys: props.modelValue,
  onChange: (keys: (number | string)[]) => {
    selectionError.value = '';
    emit('update:modelValue', keys.map(Number));
  },
  getCheckboxProps: (record: MesWmProductSalesApi.PickingBag) => ({
    disabled: props.disabled || !record.selectable,
  }),
}));
const columns = [
  { title: '袋码', dataIndex: 'code', key: 'code', ellipsis: true },
  { title: '业务码', dataIndex: 'businessCode', key: 'businessCode', width: 160, ellipsis: true },
  { title: '托盘', dataIndex: 'palletId', key: 'palletId', width: 90 },
  { title: '状态', key: 'status', width: 100 },
  { title: '说明', dataIndex: 'unavailableReason', key: 'reason', width: 180, ellipsis: true },
];
function chooseQuantity() {
  const count = Number(desired.value);
  if (!Number.isInteger(count) || count < 1 || count > eligible.value.length) {
    selectionError.value = `请输入 1 è‡³ ${eligible.value.length} çš„æ•´æ•°è¢‹æ•°`; return;
    selectionError.value = `请输入 1 è‡³ ${eligible.value.length} çš„æ•´æ•°è¢‹æ•°`;
    return;
  }
  selectionError.value = '';
  emit('update:modelValue', eligible.value.slice(0, count).map(b => b.id));
  emit('update:modelValue', eligible.value.slice(0, count).map((bag) => bag.id));
}
function clearAll() {
  selectionError.value = '';
  emit('update:modelValue', []);
}
function removeBag(id: number) {
  emit('update:modelValue', props.modelValue.filter((bagId) => bagId !== id));
}
function rowClassName(record: MesWmProductSalesApi.PickingBag) {
  return record.selectable ? '' : 'picking-row-disabled';
}
</script>
<template>
  <fieldset class="pick-bags" :disabled="disabled">
    <legend>选择追溯袋码</legend>
    <p class="pick-summary" role="status">可选 {{eligible.length}} è¢‹ Â· æœ¬æ¬¡å·²é€‰ <strong>{{modelValue.length}}</strong> è¢‹</p>
    <div class="pick-controls">
      <label>本次拣货数量<input v-model="desired" type="number" min="1" :max="eligible.length" step="1" placeholder="输入袋数" /></label>
      <button type="button" :disabled="!eligible.length" @click="chooseQuantity">按数量选择袋码</button>
      <button type="button" :disabled="!modelValue.length" @click="emit('update:modelValue', [])">清空选择</button>
  <div class="rounded-lg border border-gray-200 p-3">
    <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
      <div class="flex items-center gap-2">
        <span class="font-semibold">拣货袋码</span>
        <a-tag color="blue">可选 {{ eligible.length }} è¢‹</a-tag>
        <a-tag color="green">已选 {{ modelValue.length }} è¢‹</a-tag>
        <a-tag v-if="unavailableCount > 0" color="default">不可选 {{ unavailableCount }} è¢‹</a-tag>
      </div>
      <div class="flex flex-wrap items-center gap-2">
        <a-input
          v-model:value="search"
          allow-clear
          placeholder="搜索袋码 / ä¸šåŠ¡ç "
          style="width: 200px"
        />
        <a-input-number
          v-model:value="desired"
          :min="1"
          :max="eligible.length"
          :precision="0"
          placeholder="袋数"
          style="width: 90px"
        />
        <a-button :disabled="disabled || !eligible.length" @click="chooseQuantity">
          æŒ‰æ•°é‡é€‰æ‹©
        </a-button>
        <a-button :disabled="disabled || !modelValue.length" @click="clearAll">
          æ¸…空选择
        </a-button>
      </div>
    </div>
    <p class="pick-help">按数量选择将替换当前选择,按袋码顺序选取可选袋。请核对实物,或逐袋勾选。</p>
    <p v-if="selectionError" class="pick-error" role="alert">{{selectionError}}</p>
    <label class="pick-search">查找袋码<input v-model="search" type="search" placeholder="输入业务码或追溯码" /></label>
    <div class="pick-list">
      <label v-for="bag in filtered.slice(0, limit)" :key="bag.id" class="pick-option" :class="{ 'pick-unavailable': !bag.selectable, 'pick-selected': modelValue.includes(bag.id) }">
        <input type="checkbox" :checked="modelValue.includes(bag.id)" :disabled="!bag.selectable" :aria-label="'选择袋码 ' + bag.code" @change="choose(bag, ($event.target as HTMLInputElement).checked)" />
        <span><strong>{{bag.code}}</strong><small v-if="bag.businessCode">业务码 {{bag.businessCode}}</small><small v-if="bag.unavailableReason">{{bag.unavailableReason}}</small></span>
      </label>
      <p v-if="!filtered.length" class="pick-help">没有匹配的袋码</p>
    <a-alert
      v-if="selectionError"
      type="error"
      show-icon
      :message="selectionError"
      class="mb-2"
    />
    <a-table
      row-key="id"
      :row-selection="rowSelection"
      :columns="columns"
      :data-source="filtered"
      :pagination="{ pageSize: 10, showSizeChanger: false, size: 'small' }"
      :scroll="{ y: 280 }"
      :row-class-name="rowClassName"
      bordered
      size="small"
    >
      <template #bodyCell="{ column, record }">
        <template v-if="column.key === 'palletId'">
          {{ record.palletId ? `#${record.palletId}` : '-' }}
        </template>
        <template v-else-if="column.key === 'status'">
          <a-tag :color="record.selectable ? 'green' : 'default'">
            {{ record.selectable ? '可选' : '不可选' }}
          </a-tag>
        </template>
        <template v-else-if="column.key === 'reason'">
          {{ record.unavailableReason || '-' }}
        </template>
      </template>
    </a-table>
    <div v-if="selectedBags.length" class="mt-3 flex flex-wrap items-center gap-1">
      <span class="text-sm text-gray-500">已选袋码:</span>
      <a-tag
        v-for="bag in visibleSelected"
        :key="bag.id"
        :closable="!disabled"
        @close="removeBag(bag.id)"
      >
        {{ bag.code }}
      </a-tag>
      <a-tag v-if="selectedBags.length > VISIBLE_SELECTED">
        +{{ selectedBags.length - VISIBLE_SELECTED }}
      </a-tag>
    </div>
    <button v-if="filtered.length > limit" type="button" @click="limit += 50">显示更多袋码({{limit}} / {{filtered.length}})</button>
    <details v-if="modelValue.length"><summary>核对全部已选袋码({{modelValue.length}} è¢‹ï¼‰</summary><p v-for="bag in bags.filter(b => modelValue.includes(b.id))" :key="bag.id" class="pick-code">{{bag.code}}<small v-if="bag.businessCode"> Â· {{bag.businessCode}}</small></p></details>
  </fieldset>
  </div>
</template>
<style scoped>
.pick-bags{border:0;padding:0;margin:16px 0;min-width:0;color:inherit}.pick-bags legend{font-weight:600}.pick-summary{margin:8px 0}.pick-controls{display:flex;align-items:flex-end;gap:8px;flex-wrap:wrap}.pick-controls label{flex:1;min-width:130px}.pick-bags label{font-weight:500}.pick-bags input[type=number],.pick-bags input[type=search]{display:block;width:100%;min-width:0;min-height:44px;border:1px solid #64748b;border-radius:6px;background:var(--background, white);color:inherit;padding:8px 10px;font:inherit}.pick-bags button{min-height:44px;padding:8px 12px;border:1px solid #64748b;border-radius:6px;color:#0369a1;background:var(--background, white);cursor:pointer}.pick-bags button:disabled{opacity:.5;cursor:default}.pick-help{font-size:14px;color:#475569;line-height:1.5;margin:10px 0}.pick-search{display:block;margin:12px 0}.pick-list{max-height:360px;overflow:auto;border-top:1px solid #e2e8f0}.pick-option{display:flex;align-items:center;gap:12px;min-height:64px;padding:10px 4px;border-bottom:1px solid #e2e8f0;cursor:pointer}.pick-option input{width:20px;height:20px;flex-shrink:0;accent-color:#0369a1}.pick-option span{min-width:0;overflow-wrap:anywhere}.pick-option strong{display:block;font-weight:500}.pick-option small{display:block;color:#475569;font-size:14px}.pick-selected{background:#f0f9ff;color:#0f172a}.pick-unavailable{cursor:default;background:#f8fafc;color:#475569}.pick-error{color:#b91c1c}.pick-code{overflow-wrap:anywhere;font-size:14px;padding:6px 0}.pick-bags summary{cursor:pointer;min-height:44px;padding:10px 0;color:#0369a1}.pick-bags :focus-visible{outline:2px solid #0369a1;outline-offset:2px}
:deep(.picking-row-disabled) {
  color: #94a3b8;
  background: #f8fafc;
}
</style>
src/views/wls/returnissue/data.ts
@@ -21,11 +21,7 @@
import { MdWorkstationSelect } from '#/views/mes/md/workstation/components';
import { ProWorkOrderSelect } from '#/views/mes/pro/workorder/components';
import { WmMaterialStockSelect } from '#/views/wls/materialstock/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'finish' | 'stock' | 'update';
@@ -431,16 +427,6 @@
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区名称',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位名称',
      minWidth: 100,
    },
    {
      field: 'quantity',
      title: '数量',
      width: 100,
@@ -544,30 +530,6 @@
      // componentProps: {
      //   excludeCodes: ['WIP_VIRTUAL_WAREHOUSE'],
      // },
    },
    {
      fieldName: 'locationId',
      label: '上架目标库区',
      component: markRaw(WmWarehouseLocationSelect),
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          disabled: !values.warehouseId,
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '上架目标库位',
      component: markRaw(WmWarehouseAreaSelect),
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values) => ({
          disabled: !values.locationId,
          locationId: values.locationId,
        }),
      },
    },
    {
      fieldName: 'batchCode',
src/views/wls/returnsales/data.ts
@@ -15,11 +15,7 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import { WmBatchSelect } from '#/views/wls/batch/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
import {
  WmReturnSalesProductSalesLineSelect,
@@ -446,16 +442,6 @@
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区名称',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位名称',
      minWidth: 100,
    },
    {
      field: 'quantity',
      title: '数量',
      width: 100,
@@ -489,52 +475,9 @@
      label: '入库仓库',
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        // åˆ‡æ¢ä»“库后清空库区和库位
        onChange: async () => {
          await formApi?.setValues({
            areaId: undefined,
            locationId: undefined,
          });
        },
        placeholder: '请选择仓库',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        placeholder: '请选择库区',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          // åˆ‡æ¢åº“区后清空库位
          onChange: async () => {
            await formApi?.setFieldValue('areaId', undefined);
          },
          placeholder: '请选择库区',
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        placeholder: '请选择库位',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values) => ({
          locationId: values.locationId,
          placeholder: '请选择库位',
        }),
      },
    },
    {
      fieldName: 'batchCode',
src/views/wls/returnsales/index.vue
@@ -19,9 +19,15 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ReturnQualityForm from './modules/return-quality-form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [QualityModal, qualityModalApi] = useVbenModal({
  connectedComponent: ReturnQualityForm,
  destroyOnClose: true,
});
@@ -53,6 +59,11 @@
/** æ‰§è¡Œé€€è´§ */
function handleFinish(row: MesWmReturnSalesApi.ReturnSales) {
  formModalApi.setData({ formType: 'finish', id: row.id }).open();
}
/** é€€è´§è´¨æ£€ */
function handleQuality(row: MesWmReturnSalesApi.ReturnSales) {
  qualityModalApi.setData({ id: row.id }).open();
}
/** åˆ é™¤é”€å”®é€€è´§å• */
@@ -115,7 +126,9 @@
</script>
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <QualityModal @success="handleRefresh" />
    <Grid table-title="销售退货单列表">
      <template #toolbar-tools>
@@ -167,6 +180,13 @@
              },
            },
            {
              label: '退货质检',
              type: 'link',
              auth: ['mes:qc-seed-quality:submit'],
              ifShow: row.status === MesWmReturnSalesStatusEnum.CONFIRMED,
              onClick: handleQuality.bind(null, row),
            },
            {
              label: '执行退货',
              type: 'link',
              auth: ['mes:wm-return-sales:finish'],
src/views/wls/returnsales/modules/return-quality-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,225 @@
<script lang="ts" setup>
import type { SeedReturnQualityInfo } from '#/api/mes/qc/seedQuality';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import {
  resolveSeedReturnQuality,
  submitSeedReturnQuality,
} from '#/api/mes/qc/seedQuality';
interface IndicatorForm {
  code: string;
  label: string;
  unit: string;
  value?: number;
  minValue?: number;
  maxValue?: number;
}
const emit = defineEmits(['success']);
const info = ref<SeedReturnQualityInfo>();
const indicators = ref<IndicatorForm[]>([]);
const reason = ref('');
const returnSalesId = ref<number>();
const existing = computed(() => !!info.value?.qualityId);
const allEntered = computed(
  () =>
    indicators.value.length > 0 &&
    indicators.value.every(
      (item) =>
        item.value !== undefined &&
        item.value !== null &&
        item.minValue !== undefined &&
        item.maxValue !== undefined,
    ),
);
const passed = computed(
  () =>
    allEntered.value &&
    indicators.value.every(
      (item) =>
        Number(item.value) >= Number(item.minValue) &&
        Number(item.value) <= Number(item.maxValue),
    ),
);
function buildIndicators(data: SeedReturnQualityInfo) {
  indicators.value = (data.indicators ?? []).map((item) => ({
    code: item.code,
    label: item.name || item.code,
    unit: item.unit || '',
    value: undefined,
    minValue:
      item.minValue === null || item.minValue === undefined
        ? undefined
        : Number(item.minValue),
    maxValue:
      item.maxValue === null || item.maxValue === undefined
        ? undefined
        : Number(item.maxValue),
  }));
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!returnSalesId.value) {
      return;
    }
    if (existing.value) {
      await modalApi.close();
      return;
    }
    if (!indicators.value.length) {
      message.warning('该产品尚未配置质检项,请先维护质检项合格范围');
      return;
    }
    if (!allEntered.value) {
      message.warning('请填写全部质检项的实测值和合格上下限');
      return;
    }
    if (
      indicators.value.some(
        (item) =>
          Number(item.minValue) < 0 ||
          Number(item.maxValue) < 0 ||
          Number(item.minValue) > Number(item.maxValue),
      )
    ) {
      message.warning('质检项下限不能大于上限,且不能为负数');
      return;
    }
    if (!passed.value && !reason.value.trim()) {
      message.warning('质检不合格时请填写原因');
      return;
    }
    modalApi.lock();
    try {
      await submitSeedReturnQuality({
        returnSalesId: returnSalesId.value,
        reason: reason.value.trim() || undefined,
        indicators: indicators.value.map((item) => ({
          code: item.code,
          value: Number(item.value),
          minValue: Number(item.minValue),
          maxValue: Number(item.maxValue),
        })),
      });
      message.success('退货质检已提交,待销售部领导审批');
      await modalApi.close();
      emit('success');
    } catch (error) {
      message.error((error as Error).message || '退货质检提交失败');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      info.value = undefined;
      indicators.value = [];
      reason.value = '';
      return;
    }
    const data = modalApi.getData<{ id: number }>();
    returnSalesId.value = data.id;
    modalApi.lock();
    try {
      info.value = await resolveSeedReturnQuality(data.id);
      buildIndicators(info.value);
    } catch (error) {
      message.error((error as Error).message || '读取退货单质检信息失败');
      await modalApi.close();
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="existing ? '退货质检结果' : '经销商退货质检'" class="w-3/5">
    <div v-if="info" class="quality-body">
      <p class="quality-meta">
        é€€è´§å• <strong>{{ info.returnSalesCode }}</strong>
        <span v-if="info.batchCode"> Â· æ‰¹æ¬¡ {{ info.batchCode }}</span>
      </p>
      <a-alert
        v-if="existing"
        type="success"
        show-icon
        message="该退货单已完成质检"
        description="如需重新检验,请到「种子质检 - è´¨æ£€è®°å½•」中按流程发起复检。"
      />
      <template v-else>
        <a-alert
          type="info"
          show-icon
          message="质检不合格的退货单将自动取消,不予退货并保留检验记录。"
        />
        <a-alert
          v-if="!indicators.length"
          type="warning"
          show-icon
          message="该产品尚未配置质检项,请先在「质检项合格范围配置」中维护"
        />
        <div
          v-for="item in indicators"
          :key="item.code"
          class="quality-row"
        >
          <strong>{{ item.label }}<small v-if="item.unit">({{ item.unit }})</small></strong>
          <a-input-number
            v-model:value="item.value"
            :min="0"
            :precision="3"
            placeholder="实测值"
          />
          <a-input-number
            v-model:value="item.minValue"
            :min="0"
            :precision="3"
            placeholder="下限"
          />
          <a-input-number
            v-model:value="item.maxValue"
            :min="0"
            :precision="3"
            placeholder="上限"
          />
        </div>
        <a-textarea
          v-model:value="reason"
          :rows="3"
          :maxlength="500"
          :placeholder="passed ? '合格结果无需填写原因' : '质检不合格时请填写原因'"
        />
      </template>
    </div>
  </Modal>
</template>
<style scoped>
.quality-body {
  padding: 4px 4px 0;
}
.quality-meta {
  margin-bottom: 14px;
  color: #59677b;
}
.quality-row {
  display: grid;
  grid-template-columns: 130px repeat(3, 1fr);
  gap: 8px;
  align-items: center;
  margin: 10px 0;
}
.quality-row :deep(.ant-input-number) {
  width: 100%;
}
</style>
src/views/wls/returnvendor/data.ts
@@ -17,11 +17,7 @@
import { ErpPurchaseOrderSelect } from '#/views/erp/purchase/order/components';
import { WmBatchSelect } from '#/views/wls/batch/components';
import { WmMaterialStockSelect } from '#/views/wls/materialstock/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'finish' | 'stock' | 'update';
@@ -411,16 +407,6 @@
      minWidth: 100,
    },
    {
      field: 'locationName',
      title: '库区名称',
      minWidth: 100,
    },
    {
      field: 'areaName',
      title: '库位名称',
      minWidth: 100,
    },
    {
      field: 'quantity',
      title: '数量',
      width: 100,
@@ -462,13 +448,11 @@
      label: '库存记录',
      component: markRaw(WmMaterialStockSelect),
      componentProps: {
        // é€‰æ‹©åº“存记录后,自动回填仓库/库区/库位/批次/数量
        // é€‰æ‹©åº“存记录后,自动回填仓库/批次/数量
        onChange: async (stock?: MesWmMaterialStockApi.MaterialStock) => {
          await formApi?.setValues({
            areaId: stock?.areaId,
            batchCode: stock?.batchCode,
            batchId: stock?.batchId,
            locationId: stock?.locationId,
            quantity: stock?.quantity,
            quantityMax: stock?.quantity,
            warehouseId: stock?.warehouseId,
@@ -511,36 +495,6 @@
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        disabled: true,
      },
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          disabled: true,
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['locationId'],
        componentProps: (values) => ({
          disabled: true,
          locationId: values.locationId,
        }),
      },
    },
    {
src/views/wls/stocktaking/plan/components/condition-value-input.vue
@@ -8,15 +8,9 @@
import { Select } from 'ant-design-vue';
import { getWarehouseArea } from '#/api/mes/wm/warehouse/area';
import { getWarehouseLocation } from '#/api/mes/wm/warehouse/location';
import { MdItemSelect } from '#/views/mes/md/item/components';
import { WmBatchSelect } from '#/views/wls/batch/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
defineOptions({ name: 'StockTakingPlanConditionValueInput' });
@@ -49,10 +43,6 @@
/** å…¼å®¹ VbenForm é»˜è®¤ v-model prop ('value') å’Œ Vue 3 æ ‡å‡† ('modelValue') */
const effectiveValue = computed(() => props.value ?? props.modelValue);
const locationWarehouseId = ref<number>(); // åº“区选择器临时数据:选仓库后传给库区选择器
const areaWarehouseId = ref<number>(); // åº“位选择器临时数据:选仓库后传给库区选择器
const areaLocationId = ref<number>(); // åº“位选择器临时数据:选库区后传给库位选择器
const qualityStatusValue = ref<number>();
@@ -91,58 +81,21 @@
  });
}
/** åº“区仓库选择回调:清空库区 */
function handleLocationWarehouseChange() {
  emit('update:modelValue', undefined);
  emit('update:value', undefined);
  emit('valueChange', { valueId: undefined, valueCode: '', valueName: '' });
}
/** åº“位仓库选择回调:清空库区和库位 */
function handleAreaWarehouseChange() {
  areaLocationId.value = undefined;
  emit('update:modelValue', undefined);
  emit('update:value', undefined);
  emit('valueChange', { valueId: undefined, valueCode: '', valueName: '' });
}
/** åº“位库区选择回调:清空库位 */
function handleAreaLocationChange() {
  emit('update:modelValue', undefined);
  emit('update:value', undefined);
  emit('valueChange', { valueId: undefined, valueCode: '', valueName: '' });
}
/** ç¼–辑时回填级联选择器的上级数据(库区所属仓库、库位所属仓库/库区) */
async function loadCascadeData() {
/** ç¼–辑时回填质量状态 */
function loadCascadeData() {
  if (props.type === WlsStockTakingParamTypeEnum.QUALITY_STATUS) {
    qualityStatusValue.value = props.valueCode
      ? Number(props.valueCode)
      : undefined;
    return;
  }
  if (!effectiveValue.value) {
    return;
  }
  if (props.type === WlsStockTakingParamTypeEnum.LOCATION) {
    const location = await getWarehouseLocation(effectiveValue.value);
    locationWarehouseId.value = location?.warehouseId;
  } else if (props.type === WlsStockTakingParamTypeEnum.AREA) {
    const area = await getWarehouseArea(effectiveValue.value);
    areaWarehouseId.value = area?.warehouseId;
    areaLocationId.value = area?.locationId;
  }
}
// æ¡ä»¶ç±»åž‹å˜åŒ–时重置级联临时数据;编辑回显时按类型加载上级数据
// æ¡ä»¶ç±»åž‹å˜åŒ–时重置临时数据;编辑回显时按类型加载
watch(
  () => props.type,
  () => {
    locationWarehouseId.value = undefined;
    areaWarehouseId.value = undefined;
    areaLocationId.value = undefined;
    qualityStatusValue.value = undefined;
    void loadCascadeData();
    loadCascadeData();
  },
  { immediate: true },
);
@@ -154,47 +107,6 @@
    :model-value="effectiveValue"
    @change="handleSelectorChange"
  />
  <div
    v-else-if="type === WlsStockTakingParamTypeEnum.LOCATION"
    class="space-y-2"
  >
    <WmWarehouseSelect
      v-model="locationWarehouseId"
      placeholder="请选择仓库"
      @change="handleLocationWarehouseChange"
    />
    <WmWarehouseLocationSelect
      v-if="locationWarehouseId"
      :model-value="effectiveValue"
      :warehouse-id="locationWarehouseId"
      placeholder="请选择库区"
      @change="handleSelectorChange"
    />
  </div>
  <div
    v-else-if="type === WlsStockTakingParamTypeEnum.AREA"
    class="space-y-2"
  >
    <WmWarehouseSelect
      v-model="areaWarehouseId"
      placeholder="请选择仓库"
      @change="handleAreaWarehouseChange"
    />
    <WmWarehouseLocationSelect
      v-if="areaWarehouseId"
      v-model="areaLocationId"
      placeholder="请选择库区"
      :warehouse-id="areaWarehouseId"
      @change="handleAreaLocationChange"
    />
    <WmWarehouseAreaSelect
      v-if="areaLocationId"
      :model-value="effectiveValue"
      :location-id="areaLocationId"
      placeholder="请选择库位"
      @change="handleSelectorChange"
    />
  </div>
  <MdItemSelect
    v-else-if="type === WlsStockTakingParamTypeEnum.ITEM"
    :model-value="effectiveValue"
src/views/wls/stocktaking/plan/data.ts
@@ -11,6 +11,7 @@
  CommonStatusEnum,
  DICT_TYPE,
  MesAutoCodeRuleCode,
  WlsStockTakingParamTypeEnum,
  WlsStockTakingTypeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
@@ -304,10 +305,17 @@
      label: '条件类型',
      component: 'Select',
      componentProps: {
        options: getDictOptions(
          DICT_TYPE.MES_WM_STOCK_TAKING_PLAN_PARAM_TYPE,
          'number',
        ) as NumberDictDataType[],
        // ä¸å†æ”¯æŒæŒ‰åº“区/库位盘点
        options: (
          getDictOptions(
            DICT_TYPE.MES_WM_STOCK_TAKING_PLAN_PARAM_TYPE,
            'number',
          ) as NumberDictDataType[]
        ).filter(
          (item) =>
            item.value !== WlsStockTakingParamTypeEnum.LOCATION &&
            item.value !== WlsStockTakingParamTypeEnum.AREA,
        ),
        placeholder: '请选择条件类型',
        // æ¡ä»¶ç±»åž‹å˜åŒ–:清空已选条件值,避免残留旧类型的条件值
        onChange: async () => {
src/views/wls/stocktaking/task/data.ts
@@ -22,11 +22,7 @@
import { getRangePickerDefaultProps } from '#/utils';
import { MdItemSelect } from '#/views/mes/md/item/components';
import { StockTakingPlanSelect } from '#/views/wls/stocktaking/plan/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'execute' | 'submit' | 'update';
@@ -366,16 +362,6 @@
      minWidth: 120,
    },
    {
      field: 'locationName',
      title: '库区',
      minWidth: 120,
    },
    {
      field: 'areaName',
      title: '库位',
      minWidth: 120,
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 100,
@@ -428,16 +414,6 @@
      minWidth: 120,
    },
    {
      field: 'locationName',
      title: '库区',
      minWidth: 120,
    },
    {
      field: 'areaName',
      title: '库位',
      minWidth: 120,
    },
    {
      field: 'quantity',
      title: '数量',
      minWidth: 120,
@@ -472,23 +448,19 @@
      component: 'Select',
      componentProps: {
        allowClear: true,
        // é€‰æ‹©ç›˜ç‚¹æ¸…单后,自动带出物料、批次和仓储位置信息
        // é€‰æ‹©ç›˜ç‚¹æ¸…单后,自动带出物料、批次和仓库信息
        onChange: async (lineId?: number) => {
          const line = taskLines.find((item) => item.id === lineId);
          await formApi?.setValues({
            areaId: line?.areaId,
            batchCode: line?.batchCode,
            batchId: line?.batchId,
            itemId: line?.itemId,
            locationId: line?.locationId,
            materialStockId: line?.materialStockId,
            warehouseId: line?.warehouseId,
          });
        },
        options: taskLines.map((line) => ({
          label: `${line.itemCode} - ${line.itemName} (${line.warehouseName}${
            line.locationName ? ` / ${line.locationName}` : ''
          }${line.areaName ? ` / ${line.areaName}` : ''})`,
          label: `${line.itemCode} - ${line.itemName} (${line.warehouseName ?? ''})`,
          value: line.id,
        })),
        placeholder: '请选择盘点清单(可选)',
@@ -538,12 +510,6 @@
      label: '仓库',
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        // ä»“库变化时清空库区和库位
        onChange: () =>
          formApi?.setValues({
            areaId: undefined,
            locationId: undefined,
          }),
        placeholder: '请选择仓库',
      },
      rules: 'selectRequired',
@@ -551,39 +517,6 @@
      dependencies: {
        triggerFields: ['lineId'],
        disabled: (values) => values.lineId !== null,
      },
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['warehouseId', 'lineId'],
        show: (values) => !!values.warehouseId,
        // é€‰ä¸­ç›˜ç‚¹æ¸…单后,库区由清单带出且禁止改动
        disabled: (values) => values.lineId !== null,
        componentProps: (values) => ({
          onChange: () => formApi?.setFieldValue('areaId', undefined),
          placeholder: '请选择库区',
          warehouseId: values.warehouseId,
        }),
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['locationId', 'lineId'],
        show: (values) => !!values.locationId,
        // é€‰ä¸­ç›˜ç‚¹æ¸…单后,库位由清单带出且禁止改动
        disabled: (values) => values.lineId !== null,
        componentProps: (values) => ({
          locationId: values.locationId,
          placeholder: '请选择库位',
        }),
      },
    },
    {
src/views/wls/stocktaking/task/modules/line-list.vue
@@ -47,10 +47,8 @@
  }
  for (const stock of rows) {
    await createStockTakingTaskLine({
      areaId: stock.areaId,
      batchId: stock.batchId,
      itemId: stock.itemId,
      locationId: stock.locationId,
      materialStockId: stock.id,
      quantity: stock.quantity,
      taskId: props.taskId,
src/views/wls/transaction/data.ts
@@ -5,11 +5,7 @@
import { markRaw } from 'vue';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** ä¸šåŠ¡ç±»åž‹é€‰é¡¹ */
const bizTypeOptions = [
@@ -61,40 +57,6 @@
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        placeholder: '请选择仓库',
      },
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      dependencies: {
        triggerFields: ['warehouseId'],
        componentProps: (values) => ({
          warehouseId: values.warehouseId,
          placeholder: '请选择库区',
        }),
        trigger: (values, formApi) => {
          if (values.locationId !== undefined) {
            void formApi.setFieldValue('locationId', undefined);
          }
        },
      },
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      dependencies: {
        triggerFields: ['warehouseId', 'locationId'],
        componentProps: (values) => ({
          locationId: values.locationId,
          placeholder: '请选择库位',
        }),
        trigger: (values, formApi) => {
          if (values.areaId !== undefined) {
            void formApi.setFieldValue('areaId', undefined);
          }
        },
      },
    },
    {
@@ -162,11 +124,6 @@
      width: 100,
    },
    {
      field: 'bizCode',
      title: '业务单号',
      minWidth: 150,
    },
    {
      field: 'itemCode',
      title: '物料编码',
      width: 120,
@@ -177,10 +134,35 @@
      minWidth: 150,
    },
    {
      field: 'specification',
      title: '规格',
      minWidth: 120,
    },
    {
      field: 'itemTypeName',
      title: '类别',
      minWidth: 120,
    },
    {
      field: 'bizCode',
      title: '业务单号',
      minWidth: 150,
    },
    {
      field: 'batchCode',
      title: '批次号',
      width: 120,
      slots: { default: 'batchCode' },
    },
    {
      field: 'palletCode',
      title: '托盘码',
      minWidth: 150,
    },
    {
      field: 'bagCode',
      title: '袋码',
      minWidth: 180,
    },
    {
      field: 'quantity',
@@ -191,16 +173,6 @@
    {
      field: 'warehouseName',
      title: '仓库',
      width: 100,
    },
    {
      field: 'locationName',
      title: '库区',
      width: 100,
    },
    {
      field: 'areaName',
      title: '库位',
      width: 100,
    },
    {
src/views/wls/transfer/data.ts
@@ -20,11 +20,7 @@
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { MdItemSelect } from '#/views/mes/md/item/components';
import { WmMaterialStockSelect } from '#/views/wls/materialstock/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
import { WmWarehouseSelect } from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType =
@@ -380,16 +376,6 @@
      title: '移出仓库',
      minWidth: 120,
    },
    {
      field: 'fromLocationName',
      title: '移出库区',
      minWidth: 120,
    },
    {
      field: 'fromAreaName',
      title: '移出库位',
      minWidth: 120,
    },
    ...(editable || stockable
      ? [
          {
@@ -427,13 +413,11 @@
      label: '选择库存',
      component: markRaw(WmMaterialStockSelect),
      componentProps: {
        // é€‰æ‹©åº“存后,自动回填物料/仓库位置/批次/数量
        // é€‰æ‹©åº“存后,自动回填物料/仓库/批次/数量
        onChange: async (stock?: MesWmMaterialStockApi.MaterialStock) => {
          await formApi?.setValues({
            batchCode: stock?.batchCode,
            batchId: stock?.batchId,
            fromAreaId: stock?.areaId,
            fromLocationId: stock?.locationId,
            fromWarehouseId: stock?.warehouseId,
            itemId: stock?.itemId,
            quantity: stock?.quantity,
@@ -490,36 +474,6 @@
      },
    },
    {
      fieldName: 'fromLocationId',
      label: '移出库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['fromWarehouseId'],
        componentProps: (values) => ({
          disabled: true,
          warehouseId: values.fromWarehouseId,
        }),
      },
    },
    {
      fieldName: 'fromAreaId',
      label: '移出库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        disabled: true,
      },
      dependencies: {
        triggerFields: ['fromLocationId'],
        componentProps: (values) => ({
          disabled: true,
          locationId: values.fromLocationId,
        }),
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
@@ -540,16 +494,6 @@
    {
      field: 'toWarehouseName',
      title: '移入仓库',
      minWidth: 100,
    },
    {
      field: 'toLocationName',
      title: '移入库区',
      minWidth: 100,
    },
    {
      field: 'toAreaName',
      title: '移入库位',
      minWidth: 100,
    },
    {
@@ -589,38 +533,6 @@
        placeholder: '请选择移入仓库',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'toLocationId',
      label: '移入库区',
      component: markRaw(WmWarehouseLocationSelect),
      componentProps: {
        placeholder: '请选择移入库区',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['toWarehouseId'],
        componentProps: (values) => ({
          placeholder: '请选择移入库区',
          warehouseId: values.toWarehouseId,
        }),
      },
    },
    {
      fieldName: 'toAreaId',
      label: '移入库位',
      component: markRaw(WmWarehouseAreaSelect),
      componentProps: {
        placeholder: '请选择移入库位',
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['toLocationId'],
        componentProps: (values) => ({
          locationId: values.toLocationId,
          placeholder: '请选择移入库位',
        }),
      },
    },
    {
      fieldName: 'quantity',
src/views/wls/transfer/modules/line-list.vue
@@ -80,8 +80,6 @@
        defaults: {
          batchCode: stock.batchCode,
          batchId: stock.batchId,
          fromAreaId: stock.areaId,
          fromLocationId: stock.locationId,
          fromWarehouseId: stock.warehouseId,
          itemId: stock.itemId,
          materialStockId: stock.id,
src/views/wls/warehouse/area/data.ts
ÎļþÒÑɾ³ý
src/views/wls/warehouse/area/index.vue
ÎļþÒÑɾ³ý
src/views/wls/warehouse/area/modules/form.vue
ÎļþÒÑɾ³ý
src/views/wls/warehouse/components/area-select.vue
ÎļþÒÑɾ³ý
src/views/wls/warehouse/components/index.ts
@@ -1,3 +1 @@
export { default as WmWarehouseAreaSelect } from './area-select.vue';
export { default as WmWarehouseLocationSelect } from './location-select.vue';
export { default as WmWarehouseSelect } from './select.vue';
src/views/wls/warehouse/components/location-select.vue
ÎļþÒÑɾ³ý
src/views/wls/warehouse/index.vue
@@ -3,7 +3,6 @@
import type { MesWmWarehouseApi } from '#/api/mes/wm/warehouse';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { BarcodeBizTypeEnum } from '@vben/constants';
@@ -19,8 +18,6 @@
import Form from './modules/form.vue';
defineOptions({ name: 'WlsWarehouse' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -62,14 +59,6 @@
  } finally {
    hideLoading();
  }
}
/** è·³è½¬åº“区列表 */
function handleOpenLocation(row: MesWmWarehouseApi.Warehouse) {
  router.push({
    path: '/wls/location',
    query: { warehouseId: String(row.id) },
  });
}
/** æŸ¥çœ‹ä»“库条码 */
@@ -139,12 +128,6 @@
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '库区',
              type: 'link',
              auth: ['mes:wm-warehouse:query'],
              onClick: handleOpenLocation.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
src/views/wls/warehouse/location/data.ts
ÎļþÒÑɾ³ý
src/views/wls/warehouse/location/index.vue
ÎļþÒÑɾ³ý
src/views/wls/warehouse/location/modules/form.vue
ÎļþÒÑɾ³ý