b369a75195a37e4d67aa9cecdd8dbe4ad93689ba..dc1d067c566bbde1c7170186960a8bd27f210a47
2026-09-09 云
feat(bi): 新增决策总览仪表盘并优化预测分析功能
dc1d06 对比 | 目录
2026-09-09 云
feat(wm): 新增SN码管理和设备入库功能
1c1af9 对比 | 目录
已修改33个文件
已添加21个文件
5049 ■■■■■ 文件已修改
src/api/bi/decision/forecast.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bi/decision/kpi.ts 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/training/index.ts 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/work-hour-summary/index.ts 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/dv/machinery/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/barcode/index.ts 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/equipmentreceipt/index.ts 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/equipmentreceipt/line/index.ts 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/miscissue/index.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/miscreceipt/index.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/returnvendor/index.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/sn/index.ts 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/stocktaking/plan/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/stocktaking/task/index.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/table-action/icons.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-mes-enum.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/data.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/index.vue 270 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/decision/forecast-analysis/index.vue 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/decision/kpi-config/data.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/decision/kpi-dashboard/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/decision/trend-analysis/index.vue 232 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/training/data.ts 243 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/training/index.vue 263 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/training/modules/form.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/training/modules/participants.vue 278 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/workHourSummary/data.ts 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/workHourSummary/index.vue 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/barcode/components/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/barcode/components/scan-modal.vue 178 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/barcode/index.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/components/process-select-modal.vue 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/equipmentreceipt/data.ts 342 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/equipmentreceipt/index.vue 243 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/equipmentreceipt/modules/form.vue 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/equipmentreceipt/modules/line-form.vue 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/equipmentreceipt/modules/line-list.vue 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/miscissue/index.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/miscissue/modules/form.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/miscreceipt/index.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/miscreceipt/modules/form.vue 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnvendor/index.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnvendor/modules/form.vue 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/sn/data.ts 289 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/sn/index.vue 298 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/sn/location-maps.ts 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/sn/modules/in-stock-modal.vue 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/sn/modules/out-stock-modal.vue 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/sn/modules/sn-detail-modal.vue 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/stocktaking/plan/data.ts 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/stocktaking/task/data.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/stocktaking/task/index.vue 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/stocktaking/task/modules/form.vue 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transaction/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bi/decision/forecast.ts
@@ -15,6 +15,23 @@
    dimension?: string;
    dimensionValue?: string;
  }
  export type ForecastModel = 'SMA' | 'WMA' | 'LR' | 'SEASONAL' | 'YOY';
  export interface GenerateParams {
    model?: ForecastModel;
    window?: number;
    period?: number;
  }
  export interface GenerateKpiParams {
    kpiCode: string;
    model?: ForecastModel;
    window?: number;
    period?: number;
    periods?: number;
    intervalMinutes?: number;
  }
}
export function getForecastList(forecastCode: string, params?: Record<string, unknown>) {
@@ -23,9 +40,18 @@
  });
}
export function generateForecast(forecastCode: string) {
export function generateForecast(
  forecastCode: string,
  params?: DecisionForecastApi.GenerateParams,
) {
  return requestClient.post('/bi/decision/forecast/generate', null, {
    params: { forecastCode },
    params: { forecastCode, ...params },
  });
}
export function generateKpiForecast(params: DecisionForecastApi.GenerateKpiParams) {
  return requestClient.post<number>('/bi/decision/forecast/generate-from-kpi', null, {
    params,
  });
}
src/api/bi/decision/kpi.ts
@@ -34,6 +34,35 @@
    status?: number;
    remark?: string;
  }
  /** è¶‹åŠ¿å¿«ç…§ç‚¹ */
  export interface KpiTrendPoint {
    time: string;
    value: number;
  }
  /** è¶‹åŠ¿æ•°æ® */
  export interface KpiTrend {
    kpiCode: string;
    kpiName: string;
    category: string;
    unit: string;
    periodType: string;
    points: KpiTrendPoint[];
  }
  /** çŽ¯æ¯”ç ”åˆ¤ */
  export interface KpiCompare {
    currentValue?: number;
    currentTime?: string;
    previousValue?: number;
    previousTime?: string;
    changeRate?: number | null;
    avgValue?: number;
    maxValue?: number;
    minValue?: number;
    periodCount: number;
  }
}
export function getKpiOverview() {
@@ -52,6 +81,19 @@
  return requestClient.post<number>(`/bi/decision/kpi/refresh/${kpiCode}`);
}
export function getKpiTrend(
  kpiCode: string,
  params?: { beginTime?: string; endTime?: string; periodType?: string },
) {
  return requestClient.get<DecisionKpiApi.KpiTrend>(`/bi/decision/kpi/trend/${kpiCode}`, {
    params,
  });
}
export function getKpiCompare(kpiCode: string) {
  return requestClient.get<DecisionKpiApi.KpiCompare>(`/bi/decision/kpi/compare/${kpiCode}`);
}
export function getKpiDefinitionPage(params: Record<string, unknown>) {
  return requestClient.get('/bi/decision/kpi-definition/page', { params });
}
src/api/hrm/training/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
import type { PageParam, PageResult } from '#/packages/effects/request/src';
import { requestClient } from '#/api/request';
export namespace HrmTrainingApi {
  /** åŸ¹è®­å‚训人员 */
  export interface Participant {
    id?: number;
    trainingId?: number;
    employeeId?: number;
    employeeNo?: string;
    employeeName?: string;
    attended?: boolean;
    score?: number;
    isPass?: boolean;
    certificateId?: number;
    remark?: string;
    createTime?: string;
  }
  /** åŸ¹è®­æ´»åЍ */
  export interface Training {
    id?: number;
    trainingNo?: string;
    name?: string;
    trainingType?: number;
    trainingTypeName?: string;
    trainer?: string;
    location?: string;
    hours?: number;
    startDate?: string;
    endDate?: string;
    issueCertificate?: boolean;
    certValidityMonths?: number;
    status?: number;
    statusName?: string;
    participantCount?: number;
    participants?: Participant[];
    remark?: string;
    createTime?: string;
  }
  /** åŸ¹è®­æ´»åŠ¨æŸ¥è¯¢å‚æ•° */
  export interface TrainingPageParam extends PageParam {
    trainingNo?: string;
    name?: string;
    trainingType?: number;
    status?: number;
  }
}
/** æŸ¥è¯¢åŸ¹è®­æ´»åŠ¨åˆ†é¡µ */
export function getTrainingPage(params: HrmTrainingApi.TrainingPageParam) {
  return requestClient.get<PageResult<HrmTrainingApi.Training>>(
    '/hrm/training/page',
    { params },
  );
}
/** æŸ¥è¯¢åŸ¹è®­æ´»åŠ¨è¯¦æƒ…ï¼ˆå«å‚è®­äººå‘˜ï¼‰ */
export function getTraining(id: number) {
  return requestClient.get<HrmTrainingApi.Training>(`/hrm/training/get?id=${id}`);
}
/** åˆ›å»ºåŸ¹è®­æ´»åЍ */
export function createTraining(data: ReqTraining) {
  return requestClient.post('/hrm/training/create', data);
}
/** æ›´æ–°åŸ¹è®­æ´»åЍ */
export function updateTraining(data: ReqTraining) {
  return requestClient.put('/hrm/training/update', data);
}
/** åˆ é™¤åŸ¹è®­æ´»åЍ */
export function deleteTraining(ids: number[]) {
  return requestClient.delete('/hrm/training/delete', { params: { ids: ids.join(',') } });
}
/** å¼€å§‹åŸ¹è®­ï¼ˆè‰ç¨¿ â†’ å¾…开展) */
export function startTraining(id: number) {
  return requestClient.post('/hrm/training/start', null, { params: { id } });
}
/** å®ŒæˆåŸ¹è®­ï¼ˆè‰ç¨¿/待开展 â†’ å·²å®Œæˆï¼‰ */
export function completeTraining(id: number) {
  return requestClient.post('/hrm/training/complete', null, { params: { id } });
}
/** å–消培训(草稿/待开展 â†’ å·²å–消) */
export function cancelTraining(id: number) {
  return requestClient.post('/hrm/training/cancel', null, { params: { id } });
}
/** æ‰¹é‡æ·»åŠ å‚è®­å‘˜å·¥ */
export function addTrainingParticipants(trainingId: number, employeeIds: number[]) {
  return requestClient.post('/hrm/training-participant/add', { trainingId, employeeIds });
}
/** æ›´æ–°å‚训记录 */
export function updateTrainingParticipant(data: ReqParticipant) {
  return requestClient.put('/hrm/training-participant/update', data);
}
/** åˆ é™¤å‚训记录 */
export function deleteTrainingParticipants(ids: number[]) {
  return requestClient.delete('/hrm/training-participant/delete', {
    params: { ids: ids.join(',') },
  });
}
/** èŽ·å–æŸæ¬¡åŸ¹è®­çš„å‚è®­äººå‘˜åˆ—è¡¨ */
export function getTrainingParticipants(trainingId: number) {
  return requestClient.get<HrmTrainingApi.Participant[]>(
    `/hrm/training-participant/list?trainingId=${trainingId}`,
  );
}
/** åŸ¹è®­æ´»åŠ¨æ–°å¢ž/更新请求 */
export interface ReqTraining {
  id?: number;
  name?: string;
  trainingType?: number;
  trainer?: string;
  location?: string;
  hours?: number;
  startDate?: string;
  endDate?: string;
  issueCertificate?: boolean;
  certValidityMonths?: number;
  remark?: string;
}
/** å‚训记录更新请求 */
export interface ReqParticipant {
  id?: number;
  attended?: boolean;
  score?: number;
  isPass?: boolean;
  remark?: string;
}
src/api/hrm/work-hour-summary/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
import type { PageParam, PageResult } from '#/packages/effects/request/src';
import { requestClient } from '#/api/request';
export namespace HrmWorkHourSummaryApi {
  /** æœˆåº¦å·¥æ—¶æ±‡æ€» */
  export interface WorkHourSummary {
    id?: number;
    employeeId?: number; // å‘˜å·¥ID
    employeeNo?: string; // å‘˜å·¥ç¼–号
    employeeName?: string; // å‘˜å·¥å§“名
    deptId?: number; // éƒ¨é—¨ID
    deptName?: string; // éƒ¨é—¨åç§°
    period?: string; // æ ¸ç®—周期(yyyy-MM)
    workDays?: number; // å‡ºå‹¤å¤©æ•°
    workHours?: number; // æ­£å¸¸å·¥æ—¶ï¼ˆå°æ—¶ï¼‰
    overtimeHours?: number; // åŠ ç­å·¥æ—¶ï¼ˆå°æ—¶ï¼‰
    remark?: string; // å¤‡æ³¨
    createTime?: string;
  }
}
/** ç”Ÿæˆæœˆåº¦å·¥æ—¶æ±‡æ€» */
export function generateWorkHourSummary(period: string) {
  return requestClient.post('/hrm/work-hour-summary/generate-monthly', null, {
    params: { period },
  });
}
/** èŽ·å–æœˆåº¦å·¥æ—¶æ±‡æ€»åˆ†é¡µ */
export function getWorkHourSummaryPage(params: PageParam) {
  return requestClient.get<PageResult<HrmWorkHourSummaryApi.WorkHourSummary>>(
    '/hrm/work-hour-summary/page',
    { params },
  );
}
src/api/mes/dv/machinery/index.ts
@@ -13,6 +13,7 @@
    specification?: string; // è§„格型号
    machineryTypeId?: number; // è®¾å¤‡ç±»åž‹ç¼–号
    machineryTypeName?: string; // è®¾å¤‡ç±»åž‹åç§°
    itemId?: number; // è®¾å¤‡ç‰©æ–™ç¼–号(MDM ç‰©æ–™ï¼‰
    workshopId?: number; // æ‰€å±žè½¦é—´ç¼–号
    workshopName?: string; // æ‰€å±žè½¦é—´åç§°
    status?: number; // è®¾å¤‡çŠ¶æ€
src/api/mes/wm/barcode/index.ts
@@ -62,9 +62,32 @@
  return requestClient.download('/mes/wm/barcode/export-excel', { params });
}
/** ç”Ÿæˆæ¡ç å†…容 */
export function generateBarcodeContent(bizType: number, bizCode: string) {
/** ç”Ÿæˆæ¡ç å†…容(支持多占位符组合,可传入业务名称) */
export function generateBarcodeContent(
  bizType: number,
  bizCode: string,
  bizName?: string,
) {
  return requestClient.get<string>('/mes/wm/barcode/generate-content', {
    params: { bizCode, bizType },
    params: { bizCode, bizName, bizType },
  });
}
/** ç”Ÿæˆæ¡ç /二维码图片(服务端 zxing æˆå›¾ï¼Œè¿”回 PNG Base64,不落库) */
export function renderBarcodeImage(
  content: string,
  format?: number,
  width?: number,
  height?: number,
) {
  return requestClient.post<string>('/mes/wm/barcode/render', null, {
    params: { content, format, height, width },
  });
}
/** è§£æžæ¡ç å†…容(优先精确命中,其次按启用配置模板反解,返回业务字段) */
export function parseBarcodeContent(content: string) {
  return requestClient.post<Record<string, any>>('/mes/wm/barcode/parse', null, {
    params: { content },
  });
}
src/api/mes/wm/equipmentreceipt/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,81 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
import type { MesWmEquipmentReceiptLineApi } from './line';
export namespace MesWmEquipmentReceiptApi {
  /** MES è®¾å¤‡å…¥åº“单 */
  export interface EquipmentReceipt {
    id?: number; // ç¼–号
    code?: string; // å•据编码
    name?: string; // å•据名称
    receiptDate?: number; // å…¥åº“日期
    source?: string; // å…¥åº“来源
    status?: number; // å•据状态
    processInstanceId?: string; // å®¡æ‰¹æµç¨‹å®žä¾‹ç¼–号
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
    lines?: MesWmEquipmentReceiptLineApi.EquipmentReceiptLine[]; // æ˜Žç»†è¡Œ
  }
}
/** æŸ¥è¯¢è®¾å¤‡å…¥åº“单分页 */
export function getEquipmentReceiptPage(params: PageParam) {
  return requestClient.get<
    PageResult<MesWmEquipmentReceiptApi.EquipmentReceipt>
  >('/mes/wm/equipment-receipt/page', { params });
}
/** æŸ¥è¯¢è®¾å¤‡å…¥åº“单详情 */
export function getEquipmentReceipt(id: number) {
  return requestClient.get<MesWmEquipmentReceiptApi.EquipmentReceipt>(
    `/mes/wm/equipment-receipt/get?id=${id}`,
  );
}
/** æ–°å¢žè®¾å¤‡å…¥åº“单 */
export function createEquipmentReceipt(
  data: MesWmEquipmentReceiptApi.EquipmentReceipt,
) {
  return requestClient.post<number>('/mes/wm/equipment-receipt/create', data);
}
/** ä¿®æ”¹è®¾å¤‡å…¥åº“单 */
export function updateEquipmentReceipt(
  data: MesWmEquipmentReceiptApi.EquipmentReceipt,
) {
  return requestClient.put('/mes/wm/equipment-receipt/update', data);
}
/** åˆ é™¤è®¾å¤‡å…¥åº“单 */
export function deleteEquipmentReceipt(id: number) {
  return requestClient.delete(`/mes/wm/equipment-receipt/delete?id=${id}`);
}
/** æŸ¥è¯¢å¯ç”¨çš„设备入库单审批流程定义 */
export function getEquipmentReceiptApproveProcessList() {
  return requestClient.get<{ id: string; name: string; key: string }[]>(
    '/mes/wm/equipment-receipt/approval-process-list',
  );
}
/** æäº¤è®¾å¤‡å…¥åº“单审批 */
export function submitEquipmentReceiptApproval(
  id: number,
  processDefinitionKey: string,
) {
  return requestClient.put(
    `/mes/wm/equipment-receipt/submit-approval?id=${id}&processDefinitionKey=${processDefinitionKey}`,
  );
}
/** æ‰§è¡Œè®¾å¤‡å…¥åº“ */
export function finishEquipmentReceipt(id: number) {
  return requestClient.put(`/mes/wm/equipment-receipt/finish?id=${id}`);
}
/** å–消设备入库单 */
export function cancelEquipmentReceipt(id: number) {
  return requestClient.put(`/mes/wm/equipment-receipt/cancel?id=${id}`);
}
src/api/mes/wm/equipmentreceipt/line/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
import { requestClient } from '#/api/request';
export namespace MesWmEquipmentReceiptLineApi {
  /** MES è®¾å¤‡å…¥åº“单行 */
  export interface EquipmentReceiptLine {
    id?: number; // ç¼–号
    receiptId?: number; // å…¥åº“单编号
    machineryId?: number; // è®¾å¤‡å°è´¦ç¼–号
    machineryCode?: string; // è®¾å¤‡ç¼–码
    machineryName?: string; // è®¾å¤‡åç§°
    itemId?: number; // è®¾å¤‡ç‰©æ–™ç¼–号
    itemCode?: string; // ç‰©æ–™ç¼–码
    itemName?: string; // ç‰©æ–™åç§°
    specification?: string; // è§„格型号
    unitMeasureName?: string; // è®¡é‡å•位名称
    quantity?: number; // å…¥åº“数量
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
}
/** æŸ¥è¯¢è®¾å¤‡å…¥åº“单行列表 */
export function getEquipmentReceiptLineListByReceiptId(receiptId: number) {
  return requestClient.get<
    MesWmEquipmentReceiptLineApi.EquipmentReceiptLine[]
  >(`/mes/wm/equipment-receipt-line/list-by-receipt-id?receiptId=${receiptId}`);
}
/** æŸ¥è¯¢è®¾å¤‡å…¥åº“单行详情 */
export function getEquipmentReceiptLine(id: number) {
  return requestClient.get<MesWmEquipmentReceiptLineApi.EquipmentReceiptLine>(
    `/mes/wm/equipment-receipt-line/get?id=${id}`,
  );
}
/** æ–°å¢žè®¾å¤‡å…¥åº“单行 */
export function createEquipmentReceiptLine(
  data: MesWmEquipmentReceiptLineApi.EquipmentReceiptLine,
) {
  return requestClient.post('/mes/wm/equipment-receipt-line/create', data);
}
/** ä¿®æ”¹è®¾å¤‡å…¥åº“单行 */
export function updateEquipmentReceiptLine(
  data: MesWmEquipmentReceiptLineApi.EquipmentReceiptLine,
) {
  return requestClient.put('/mes/wm/equipment-receipt-line/update', data);
}
/** åˆ é™¤è®¾å¤‡å…¥åº“单行 */
export function deleteEquipmentReceiptLine(id: number) {
  return requestClient.delete(
    `/mes/wm/equipment-receipt-line/delete?id=${id}`,
  );
}
src/api/mes/wm/miscissue/index.ts
@@ -14,6 +14,7 @@
    sourceDocCode?: string; // æ¥æºå•据编码
    issueDate?: number; // å‡ºåº“日期
    status?: number; // å•据状态
    processInstanceId?: string; // å®¡æ‰¹æµç¨‹å®žä¾‹ç¼–号
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
@@ -49,9 +50,21 @@
  return requestClient.delete(`/mes/wm/misc-issue/delete?id=${id}`);
}
/** æäº¤æ‚项出库单 */
export function submitMiscIssue(id: number) {
  return requestClient.put(`/mes/wm/misc-issue/submit?id=${id}`);
/** æŸ¥è¯¢å¯ç”¨çš„æ‚项出库单审批流程定义 */
export function getMiscIssueApproveProcessList() {
  return requestClient.get<{ id: string; name: string; key: string }[]>(
    '/mes/wm/misc-issue/approval-process-list',
  );
}
/** æäº¤æ‚项出库单审批 */
export function submitMiscIssueApproval(
  id: number,
  processDefinitionKey: string,
) {
  return requestClient.put(
    `/mes/wm/misc-issue/submit?id=${id}&processDefinitionKey=${processDefinitionKey}`,
  );
}
/** æ‰§è¡Œå‡ºåº“ */
src/api/mes/wm/miscreceipt/index.ts
@@ -14,6 +14,7 @@
    sourceDocCode?: string; // æ¥æºå•据编码
    receiptDate?: number; // å…¥åº“日期
    status?: number; // å•据状态
    processInstanceId?: string; // å®¡æ‰¹æµç¨‹å®žä¾‹ç¼–号
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
@@ -49,9 +50,21 @@
  return requestClient.delete(`/mes/wm/misc-receipt/delete?id=${id}`);
}
/** æäº¤æ‚项入库单 */
export function submitMiscReceipt(id: number) {
  return requestClient.put(`/mes/wm/misc-receipt/submit?id=${id}`);
/** æŸ¥è¯¢å¯ç”¨çš„æ‚项入库单审批流程定义 */
export function getMiscReceiptApproveProcessList() {
  return requestClient.get<{ id: string; name: string; key: string }[]>(
    '/mes/wm/misc-receipt/approval-process-list',
  );
}
/** æäº¤æ‚项入库单审批 */
export function submitMiscReceiptApproval(
  id: number,
  processDefinitionKey: string,
) {
  return requestClient.put(
    `/mes/wm/misc-receipt/submit?id=${id}&processDefinitionKey=${processDefinitionKey}`,
  );
}
/** æ‰§è¡Œå…¥åº“ */
src/api/mes/wm/returnvendor/index.ts
@@ -19,6 +19,7 @@
    transportCode?: string; // è¿å•号
    transportTelephone?: string; // è”系电话
    status?: number; // å•据状态
    processInstanceId?: string; // å®¡æ‰¹æµç¨‹å®žä¾‹ç¼–号
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
@@ -54,9 +55,21 @@
  return requestClient.delete(`/mes/wm/return-vendor/delete?id=${id}`);
}
/** æäº¤ä¾›åº”商退货单 */
export function submitReturnVendor(id: number) {
  return requestClient.put(`/mes/wm/return-vendor/submit?id=${id}`);
/** æŸ¥è¯¢å¯ç”¨çš„供应商退货单审批流程定义 */
export function getReturnVendorApproveProcessList() {
  return requestClient.get<{ id: string; name: string; key: string }[]>(
    '/mes/wm/return-vendor/approval-process-list',
  );
}
/** æäº¤ä¾›åº”商退货单审批 */
export function submitReturnVendorApproval(
  id: number,
  processDefinitionKey: string,
) {
  return requestClient.put(
    `/mes/wm/return-vendor/submit?id=${id}&processDefinitionKey=${processDefinitionKey}`,
  );
}
/** æ‰§è¡Œæ‹£è´§ */
src/api/mes/wm/sn/index.ts
@@ -29,7 +29,60 @@
    unitName?: string; // å•位名称
    batchCode?: string; // æ‰¹æ¬¡å·
    workOrderId?: number; // ç”Ÿäº§è®¢å•编号
    status?: number; // çŠ¶æ€ï¼ˆ1-在库 2-已出库)
    warehouseId?: number; // åœ¨åº“仓库 ID
    locationId?: number; // åœ¨åº“库区 ID
    areaId?: number; // åœ¨åº“库位 ID
    createTime?: Date; // ç”Ÿæˆæ—¶é—´
  }
  /** MES SN ç å•件详情 */
  export interface SnDetail {
    id?: number; // ç¼–号
    code?: string; // SN ç 
    itemId?: number; // ç‰©æ–™ç¼–号
    itemCode?: string; // ç‰©æ–™ç¼–码
    itemName?: string; // ç‰©æ–™åç§°
    batchCode?: string; // æ‰¹æ¬¡å·
    status?: number; // çŠ¶æ€ï¼ˆ1-在库 2-已出库)
    warehouseId?: number; // åœ¨åº“仓库 ID
    locationId?: number; // åœ¨åº“库区 ID
    areaId?: number; // åœ¨åº“库位 ID
    lastTransactionType?: number; // æœ€è¿‘事务类型(1-入库 2-出库)
    lastTransactionTypeName?: string; // æœ€è¿‘事务类型名称
    lastTransactionQuantity?: number; // æœ€è¿‘事务变动数量
    lastTransactionBizType?: number; // æœ€è¿‘事务业务类型
    lastTransactionBizCode?: string; // æœ€è¿‘事务业务单号
    lastTransactionTime?: string; // æœ€è¿‘事务发生时间
  }
  /** SN ç å…¥åº“登记参数 */
  export interface SnInStock {
    code?: string; // SN ç 
    itemId?: number; // ç‰©æ–™ç¼–号
    warehouseId?: number; // ä»“库 ID
    locationId?: number; // åº“区 ID
    areaId?: number; // åº“位 ID
    batchCode?: string; // æ‰¹æ¬¡å·
    remark?: string; // å¤‡æ³¨
  }
  /** SN ç å‡ºåº“核销参数 */
  export interface SnOutStock {
    code?: string; // SN ç 
    itemId?: number; // ç‰©æ–™ç¼–号
    remark?: string; // å¤‡æ³¨
  }
  /** SN ç åœ¨åº“/状态分页查询参数 */
  export interface SnInStockPageParams extends PageParam {
    code?: string; // SN ç 
    itemId?: number; // ç‰©æ–™ç¼–号
    batchCode?: string; // æ‰¹æ¬¡å·
    warehouseId?: number; // ä»“库 ID
    locationId?: number; // åº“区 ID
    areaId?: number; // åº“位 ID
    status?: number; // çŠ¶æ€ï¼ˆ1-在库 2-已出库)
  }
  /** MES SN ç ç”Ÿæˆå‚æ•° */
@@ -77,6 +130,31 @@
  });
}
/** SN ç å…¥åº“登记 */
export function registerSnInStock(data: MesWmSnApi.SnInStock) {
  return requestClient.post<boolean>('/mes/wm/sn/in-stock', data);
}
/** SN ç å‡ºåº“核销 */
export function outboundSn(data: MesWmSnApi.SnOutStock) {
  return requestClient.post<boolean>('/mes/wm/sn/out-stock', data);
}
/** æŸ¥è¯¢ SN ç å•件详情 */
export function getSnDetail(code: string) {
  return requestClient.get<MesWmSnApi.SnDetail>('/mes/wm/sn/detail', {
    params: { code },
  });
}
/** æŸ¥è¯¢ SN ç åœ¨åº“/状态分页 */
export function getSnInStockPage(params: MesWmSnApi.SnInStockPageParams) {
  return requestClient.get<PageResult<MesWmSnApi.Sn>>(
    '/mes/wm/sn/in-stock-page',
    { params },
  );
}
/** å¯¼å‡º SN ç åˆ†ç»„ Excel */
export function exportSnGroupExcel(params: MesWmSnApi.PageParams) {
  return requestClient.download('/mes/wm/sn/group-export-excel', { params });
src/api/mes/wm/stocktaking/plan/index.ts
@@ -13,6 +13,9 @@
    endTime?: number; // ç»“束时间
    blindFlag?: boolean; // æ˜¯å¦ç›²ç›˜
    frozen?: boolean; // æ˜¯å¦å†»ç»“库存
    autoGenerate?: boolean; // æ˜¯å¦è‡ªåŠ¨ç”Ÿæˆç›˜ç‚¹ä»»åŠ¡
    generateCron?: string; // è‡ªåŠ¨ç”Ÿæˆå‘¨æœŸ(Cron)
    lastGenerateTime?: string; // ä¸Šæ¬¡ç”Ÿæˆä»»åŠ¡æ—¶é—´
    status?: number; // çŠ¶æ€
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
src/api/mes/wm/stocktaking/task/index.ts
@@ -20,6 +20,7 @@
    startTime?: number; // å¼€å§‹æ—¶é—´
    endTime?: number; // ç»“束时间
    status?: number; // å•据状态
    processInstanceId?: string; // å®¡æ‰¹æµç¨‹å®žä¾‹ç¼–号
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
  }
@@ -59,7 +60,7 @@
  return requestClient.delete(`/mes/wm/stocktaking-task/delete?id=${id}`);
}
/** æäº¤ç›˜ç‚¹ä»»åŠ¡ */
/** å¼€å§‹ç›˜ç‚¹ï¼ˆæäº¤ç›˜ç‚¹ä»»åŠ¡ï¼‰ */
export function submitStockTaking(id: number) {
  return requestClient.put('/mes/wm/stocktaking-task/submit', null, {
    params: { id },
@@ -73,9 +74,21 @@
  });
}
/** æ‰§è¡Œç›˜ç‚¹ä»»åŠ¡ */
export function finishStockTaking(id: number) {
  return requestClient.put('/mes/wm/stocktaking-task/finish', { id });
/** æŸ¥è¯¢å¯ç”¨çš„盘点结果审批流程定义 */
export function getStockTakingApproveProcessList() {
  return requestClient.get<{ id: string; name: string; key: string }[]>(
    '/mes/wm/stocktaking-task/approval-process-list',
  );
}
/** æäº¤ç›˜ç‚¹ç»“果审批 */
export function submitStockTakingApproval(
  id: number,
  processDefinitionKey: string,
) {
  return requestClient.put(
    `/mes/wm/stocktaking-task/submit-approval?id=${id}&processDefinitionKey=${processDefinitionKey}`,
  );
}
/** å¯¼å‡ºç›˜ç‚¹ä»»åŠ¡ */
src/components/table-action/icons.ts
@@ -14,4 +14,5 @@
  BOOK: 'lucide:book',
  AUDIT: 'lucide:file-check',
  CALC: 'lucide:calculator',
  SCAN: 'lucide:scan-line',
};
src/packages/constants/src/biz-mes-enum.ts
@@ -187,6 +187,7 @@
  PRODUCTRECPT_CODE: 'PRODUCTRECPT_CODE',
  WM_MISC_ISSUE_CODE: 'WM_MISC_ISSUE_CODE',
  WM_MISC_RECEIPT_CODE: 'WM_MISC_RECEIPT_CODE',
  WM_EQUIP_RECEIPT_CODE: 'WM_EQUIP_RECEIPT_CODE',
  WM_OUTSOURCE_ISSUE_CODE: 'WM_OUTSOURCE_ISSUE_CODE',
  WM_OUTSOURCE_RECEIPT_CODE: 'WM_OUTSOURCE_RECEIPT_CODE',
  WM_PACKAGE_CODE: 'WM_PACKAGE_CODE',
@@ -316,6 +317,7 @@
/** MES æ‚项出库单状态枚举 */
export const MesWmMiscIssueStatusEnum = {
  PREPARE: MesOrderStatusConstants.DRAFT,
  APPROVING: MesOrderStatusConstants.APPROVING,
  APPROVED: MesOrderStatusConstants.APPROVED,
  FINISHED: MesOrderStatusConstants.FINISHED,
  CANCELED: MesOrderStatusConstants.CANCELLED,
@@ -324,6 +326,7 @@
/** MES æ‚项入库单状态枚举 */
export const MesWmMiscReceiptStatusEnum = {
  PREPARE: MesOrderStatusConstants.DRAFT,
  APPROVING: MesOrderStatusConstants.APPROVING,
  APPROVED: MesOrderStatusConstants.APPROVED,
  FINISHED: MesOrderStatusConstants.FINISHED,
  CANCELED: MesOrderStatusConstants.CANCELLED,
@@ -631,3 +634,18 @@
  USER = 604,
  CLIENT = 605,
}
/** MES SN ç çŠ¶æ€æžšä¸¾ */
export enum MesWmSnStatusEnum {
  IN_STOCK = 1, // åœ¨åº“
  OUT_STOCK = 2, // å·²å‡ºåº“
}
/** MES è®¾å¤‡å…¥åº“单状态枚举 */
export const MesWmEquipmentReceiptStatusEnum = {
  PREPARE: MesOrderStatusConstants.DRAFT,
  APPROVING: MesOrderStatusConstants.APPROVING,
  APPROVED: MesOrderStatusConstants.APPROVED,
  FINISHED: MesOrderStatusConstants.FINISHED,
  CANCELED: MesOrderStatusConstants.CANCELLED,
} as const;
src/views/bi/dashboard/data.ts
@@ -10,6 +10,27 @@
/** ä»ªè¡¨ç›˜å…ƒä¿¡æ¯æ˜ å°„ */
export const DASHBOARD_META: Record<string, DashboardMeta> = {
  'decision-overview': {
    code: 'decision_overview',
    title: '决策总览',
    subtitle: '全厂核心 KPI ä¸€è§ˆä¸Žè¶‹åŠ¿ç ”åˆ¤å¤§å±',
    gradient: 'from-cyan-600 via-sky-600 to-blue-500',
    icon: 'lucide:layout-dashboard',
  },
  energy: {
    code: 'energy',
    title: '能耗监控',
    subtitle: '用电营销与能耗实时监控大屏',
    gradient: 'from-amber-500 via-orange-500 to-red-500',
    icon: 'lucide:zap',
  },
  'sales-customer': {
    code: 'sales_customer',
    title: '营销客户',
    subtitle: '销售订单与客户经营分析大屏',
    gradient: 'from-emerald-600 via-green-600 to-teal-500',
    icon: 'lucide:users',
  },
  'purchase-sales': {
    code: 'purchase_sales',
    title: '采购 Â· é”€å”® Â· å”®åŽ',
src/views/bi/dashboard/index.vue
@@ -40,21 +40,40 @@
  }
}
// ======== åˆ†ç±» ========
const numberCards = computed(() => charts.value.filter((c) => c.chartType === 'number'));
const tableCharts = computed(() => charts.value.filter((c) => c.chartType === 'table'));
const echartsCharts = computed(() => charts.value.filter((c) => !['number', 'table'].includes(c.chartType)));
// ======== 12 åˆ—栅格自适应 ========
const COMPACT_BREAKPOINT = 1180;
const isCompact = ref(false);
function updateCompact() {
  isCompact.value = window.innerWidth < COMPACT_BREAKPOINT;
}
// ======== å›¾è¡¨ Grid åˆ—跨度 ========
function isFullWidth(chart: BiDashboardApi.ChartItem): boolean {
  const w = chart.position?.w;
  if (w && w >= 24) return true;
function isNumberCard(chart: BiDashboardApi.ChartItem): boolean {
  return chart.chartType === 'number';
}
function isTableCard(chart: BiDashboardApi.ChartItem): boolean {
  return chart.chartType === 'table';
}
function getGridStyle(chart: BiDashboardApi.ChartItem): Record<string, string> {
  if (isFullWidth(chart)) return { 'grid-column': '1 / -1' };
  return {};
function chartGridStyle(chart: BiDashboardApi.ChartItem): Record<string, string> {
  const pos = chart.position;
  if (isCompact.value) {
    // çª„屏:指标卡半宽两列排布,图表/表格全宽堆叠,避免挤压
    return isNumberCard(chart) || chart.chartType === 'gauge'
      ? { gridColumn: 'auto / span 6' }
      : { gridColumn: '1 / -1' };
  }
  if (!pos) {
    return { gridColumn: '1 / -1' };
  }
  const colStart = Math.max(1, (pos.x || 0) + 1);
  const colSpan = Math.min(12, Math.max(1, pos.w || 12));
  const rowStart = Math.max(1, (pos.y || 0) + 1);
  const rowSpan = Math.max(1, pos.h || 1);
  return {
    gridColumn: `${colStart} / span ${colSpan}`,
    gridRow: `${rowStart} / span ${rowSpan}`,
  };
}
// ======== å›¾è¡¨ç¼©æ”¾ ========
@@ -140,6 +159,9 @@
// ======== çœ‹æ¿ä¸»é¢˜è‰² ========
const dashboardAccentColors: Record<string, string> = {
  'decision-overview': '#00E5FF',
  energy: '#FFC107',
  'sales-customer': '#00FF88',
  'purchase-sales': '#6366f1',
  'production-equipment': '#f59e0b',
  quality: '#f43f5e',
@@ -160,6 +182,8 @@
  if (name.includes('合同') || name.includes('协同')) return 'lucide:file-text';
  if (name.includes('订单')) return 'lucide:clipboard-list';
  if (name.includes('库存') || name.includes('仓库')) return 'lucide:package';
  if (name.includes('能耗') || name.includes('电量') || name.includes('功率') || name.includes('用能')) return 'lucide:zap';
  if (name.includes('客户')) return 'lucide:users';
  return 'lucide:bar-chart-4';
}
@@ -167,13 +191,17 @@
function getKpiColor(i: number) { return kpiColors[i % kpiColors.length]; }
watch(dashboardCode, () => loadData());
watch(isCompact, () => setTimeout(resizeAllCharts, 120));
onMounted(() => {
  document.addEventListener('fullscreenchange', onFullscreenChange);
  window.addEventListener('resize', updateCompact);
  updateCompact();
  setupResizeObserver();
  loadData();
});
onBeforeUnmount(() => {
  document.removeEventListener('fullscreenchange', onFullscreenChange);
  window.removeEventListener('resize', updateCompact);
  resizeObserver?.disconnect();
});
</script>
@@ -227,87 +255,79 @@
      <div style="height: 400px" />
    </Spin>
    <!-- ======== ä»ªè¡¨ç›˜å†…容 ======== -->
    <!-- ======== 12 åˆ—栅格布局 ======== -->
    <template v-else>
      <!-- KPI å¡ç‰‡è¡Œ -->
      <div v-if="numberCards.length > 0" class="kpi-row">
      <div v-if="charts.length > 0" class="bi-grid">
        <div
          v-for="(card, i) in numberCards"
          :key="card.id"
          class="kpi-card"
          :style="{ '--kpi-color': getKpiColor(i), animationDelay: `${i * 0.06}s` }"
          v-for="(chart, i) in charts"
          :key="chart.id"
          class="bi-cell"
          :style="chartGridStyle(chart)"
        >
          <div class="kpi-icon">
            <IconifyIcon :icon="getKpiIcon(card.name)" />
          <!-- æ•°å­—指标卡 -->
          <div
            v-if="isNumberCard(chart)"
            class="kpi-card"
            :style="{ '--kpi-color': getKpiColor(i) }"
          >
            <div class="kpi-icon">
              <IconifyIcon :icon="getKpiIcon(chart.name)" />
            </div>
            <div class="kpi-body">
              <span class="kpi-label">{{ chart.name }}</span>
              <span
                class="kpi-value"
                :ref="(el: unknown) => {
                  if (el && chart.data?.[0]) {
                    const v = extractNumberValue(chart.data[0] as Record<string, unknown>);
                    maybeAnimate(el as HTMLElement, chart.id!, v);
                  }
                }"
              >{{ getCardDisplayValue(chart) }}</span>
            </div>
          </div>
          <div class="kpi-body">
            <span class="kpi-label">{{ card.name }}</span>
            <span
              class="kpi-value"
              :ref="(el: unknown) => {
                if (el && card.data?.[0]) {
                  const v = extractNumberValue(card.data[0] as Record<string, unknown>);
                  maybeAnimate(el as HTMLElement, card.id!, v);
                }
              }"
            >{{ getCardDisplayValue(card) }}</span>
          <!-- æ•°æ®è¡¨æ ¼ -->
          <div v-else-if="isTableCard(chart)" class="table-card">
            <div class="table-header">
              <span class="table-header-dot" :style="{ background: accentColor, boxShadow: `0 0 6px ${accentColor}` }" />
              <span class="table-header-title">{{ chart.name }}</span>
              <span class="table-badge">实时滚动</span>
            </div>
            <div v-if="chart.data && chart.data.length > 0" class="table-wrap">
              <table class="data-table">
                <thead>
                  <tr>
                    <th
                      v-for="key in Object.keys(chart.data[0] || {})"
                      :key="key"
                    >{{ key }}</th>
                  </tr>
                </thead>
                <tbody>
                  <tr
                    v-for="(row, ri) in chart.data"
                    :key="ri"
                    :style="{ animationDelay: `${ri * 50}ms` }"
                  >
                    <td
                      v-for="key in Object.keys(chart.data[0] || {})"
                      :key="key"
                    >{{ getCellValue(row, key) }}</td>
                  </tr>
                </tbody>
              </table>
            </div>
            <Empty v-else description="暂无数据" />
          </div>
          <!-- ECharts å›¾è¡¨ -->
          <ChartCard v-else :chart="chart" :accent-color="accentColor" />
        </div>
      </div>
      <!-- å›¾è¡¨ Grid -->
      <div v-if="echartsCharts.length > 0" class="chart-row">
        <div
          v-for="(chart, i) in echartsCharts"
          :key="chart.id"
          :style="{ ...getGridStyle(chart), animationDelay: `${i * 0.08}s` }"
        >
          <ChartCard :chart="chart" :accent-color="accentColor" />
        </div>
      </div>
      <!-- æ•°æ®è¡¨æ ¼ -->
      <template v-if="tableCharts.length > 0">
        <div
          v-for="chart in tableCharts"
          :key="chart.id"
          class="table-card"
        >
          <div class="table-header">
            <span class="table-header-dot" :style="{ background: accentColor, boxShadow: `0 0 6px ${accentColor}` }" />
            <span class="table-header-title">{{ chart.name }}</span>
            <span class="table-badge">实时滚动</span>
          </div>
          <div v-if="chart.data && chart.data.length > 0" class="table-wrap">
            <table class="data-table">
              <thead>
                <tr>
                  <th
                    v-for="key in Object.keys(chart.data[0] || {})"
                    :key="key"
                  >{{ key }}</th>
                </tr>
              </thead>
              <tbody>
                <tr
                  v-for="(row, ri) in chart.data"
                  :key="ri"
                  :style="{ animationDelay: `${ri * 50}ms` }"
                >
                  <td
                    v-for="key in Object.keys(chart.data[0] || {})"
                    :key="key"
                  >{{ getCellValue(row, key) }}</td>
                </tr>
              </tbody>
            </table>
          </div>
          <Empty v-else description="暂无数据" />
        </div>
      </template>
      <!-- ç©ºçŠ¶æ€ -->
      <div v-if="charts.length === 0" class="empty-state">
      <div v-else class="empty-state">
        <IconifyIcon class="size-16 text-white/15" icon="lucide:bar-chart-4" />
        <p>暂未配置图表数据</p>
        <p class="empty-sub">请在配置管理中为当前仪表盘添加图表</p>
@@ -333,14 +353,17 @@
  --text-muted: rgba(145, 158, 185, 0.5);
  position: relative;
  display: flex;
  flex-direction: column;
  background:
    radial-gradient(ellipse 70% 50% at 50% 0%, #0d1f42 0%, #060e24 35%, #020817 100%);
  color: var(--text-primary);
  padding: 16px 20px 24px;
  padding: 16px 20px 20px;
  font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
  overflow-x: hidden;
  overflow-y: auto;
  max-height: calc(100vh - 104px);
  height: calc(100vh - 104px);
  min-height: 320px;
}
/* èƒŒæ™¯å…‰æ™• + ç½‘æ ¼ */
@@ -375,6 +398,7 @@
.dash-header {
  position: relative; z-index: 1;
  display: flex; align-items: center; gap: 16px;
  flex-shrink: 0;
  padding-bottom: 14px; margin-bottom: 16px;
  border-bottom: 1px solid rgba(0,229,255,0.08);
}
@@ -451,16 +475,35 @@
}
.dash-fs-btn:hover { border-color: rgba(255,255,255,0.2); color: #fff; background: rgba(255,255,255,0.04); }
/* ======== KPI è¡Œ ======== */
.kpi-row {
/* ======== 12 åˆ—æ …æ ¼ ======== */
.bi-grid {
  position: relative; z-index: 1;
  flex: 1;
  min-height: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: 12px; margin-bottom: 16px;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  grid-auto-rows: minmax(36px, 1fr);
  gap: 12px;
}
.bi-cell {
  min-width: 0;
  min-height: 0;
  animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
}
.bi-cell > * {
  height: 100%;
}
/* ======== KPI æŒ‡æ ‡å¡ ======== */
.kpi-card {
  display: flex; align-items: center; gap: 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  text-align: center;
  padding: 14px 16px;
  background: var(--bg-card);
  backdrop-filter: blur(10px);
@@ -469,8 +512,6 @@
  border-radius: 10px;
  cursor: default;
  transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
  animation: kpi-fade-up 0.55s cubic-bezier(0.4, 0, 0.2, 1) both;
  min-width: 0;
  position: relative;
  overflow: hidden;
}
@@ -491,8 +532,8 @@
.kpi-icon {
  display: flex; align-items: center; justify-content: center;
  width: 40px; height: 40px; flex-shrink: 0;
  border-radius: 8px;
  width: 42px; height: 42px; flex-shrink: 0;
  border-radius: 10px;
  background: color-mix(in srgb, var(--kpi-color, var(--cyan)) 15%, transparent);
  color: var(--kpi-color, var(--cyan));
  font-size: 18px;
@@ -500,17 +541,18 @@
}
.kpi-body {
  flex: 1; min-width: 0;
  display: flex; flex-direction: column; gap: 4px;
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  min-width: 0;
}
.kpi-label {
  font-size: 11px; color: var(--text-secondary); letter-spacing: 0.3px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  max-width: 100%;
}
.kpi-value {
  font-size: 24px; font-weight: 800; line-height: 1;
  font-size: 28px; font-weight: 800; line-height: 1.1;
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
}
@@ -520,32 +562,19 @@
  to { opacity: 1; transform: translateY(0); }
}
/* ======== å›¾è¡¨è¡Œ ======== */
.chart-row {
  position: relative; z-index: 1;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
  gap: 12px;
  margin-bottom: 16px;
}
.chart-row > div {
  min-width: 0;
  animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
}
/* ======== è¡¨æ ¼å¡ç‰‡ ======== */
.table-card {
  position: relative; z-index: 1;
  background: var(--bg-card);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 14px;
  margin-bottom: 16px;
  display: flex;
  flex-direction: column;
  min-height: 0;
  transition: all 0.35s;
  animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
  position: relative;
}
.table-card::before {
  content: '';
@@ -582,7 +611,8 @@
}
.table-wrap {
  overflow-x: auto; border-radius: 8px;
  flex: 1;
  overflow-x: auto; overflow-y: auto; border-radius: 8px;
  background: rgba(4, 14, 36, 0.55);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
@@ -643,8 +673,7 @@
.is-fullscreen::after {
  position: fixed;
}
.is-fullscreen .kpi-row { gap: 16px; }
.is-fullscreen .chart-row { gap: 16px; }
.is-fullscreen .bi-grid { gap: 16px; }
.is-fullscreen .dash-header { margin-bottom: 24px; }
/* ======== åŠ è½½æ€/空状态 ======== */
@@ -657,9 +686,6 @@
  .bi-dashboard { padding: 10px; }
  .dash-header { flex-wrap: wrap; }
  .dash-header-divider { display: none; }
  .kpi-row { grid-template-columns: 1fr 1fr; }
  .chart-row { grid-template-columns: 1fr; }
  .chart-row > div { grid-column: 1 / -1 !important; }
  .kpi-value { font-size: 20px; }
  .kpi-value { font-size: 24px; }
}
</style>
</style>
src/views/bi/decision/forecast-analysis/index.vue
@@ -1,27 +1,41 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { DatePicker, message, Select, Spin } from 'ant-design-vue';
import { Button, DatePicker, InputNumber, message, Select, Spin } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import {
  generateForecast,
  generateForecastWorkOrder,
  generateKpiForecast,
  getForecastList,
} from '#/api/bi/decision/forecast';
import type { DecisionForecastApi } from '#/api/bi/decision/forecast';
import { getKpiOverview } from '#/api/bi/decision/kpi';
import type { DecisionKpiApi } from '#/api/bi/decision/kpi';
defineOptions({ name: 'DecisionForecastAnalysis' });
const loading = ref(false);
const forecastData = ref<any[]>([]);
const generating = ref(false);
const forecastData = ref<DecisionForecastApi.ForecastItem[]>([]);
const forecastCode = ref('load_forecast_daily');
const FORECAST_OPTIONS = [
  { label: '日负荷预测', value: 'load_forecast_daily' },
  { label: '周负荷预测', value: 'load_forecast_weekly' },
  { label: '月负荷预测', value: 'load_forecast_monthly' },
];
const MODEL_OPTIONS: Array<{ label: string; value: DecisionForecastApi.ForecastModel }> = [
  { label: '简单移动平均 SMA', value: 'SMA' },
  { label: '加权移动平均 WMA', value: 'WMA' },
  { label: '线性回归 LR', value: 'LR' },
  { label: '季节指数 SEASONAL', value: 'SEASONAL' },
  { label: '同比增长 YOY', value: 'YOY' },
];
const WORK_ORDER_TYPE_OPTIONS = [
@@ -41,7 +55,74 @@
  }
}
onMounted(loadData);
/** ========== æŒ‡å®šé¢„测编码生成 ========== */
const model = ref<DecisionForecastApi.ForecastModel>('SMA');
async function handleGenerateForecast() {
  generating.value = true;
  try {
    await generateForecast(forecastCode.value, { model: model.value });
    message.success(`已生成 ${forecastCode.value} é¢„测`);
    await loadData();
  } catch {
    message.error('生成失败,请检查预测配置');
  } finally {
    generating.value = false;
  }
}
/** ========== åŸºäºŽ KPI åŽ†å²æ»šåŠ¨é¢„æµ‹ ========== */
const kpiList = ref<DecisionKpiApi.KpiItem[]>([]);
const selectedKpi = ref<string>('');
const kpiModel = ref<DecisionForecastApi.ForecastModel>('SMA');
const forecastPeriods = ref(30);
const intervalMinutes = ref(360);
const kpiOptions = computed(() =>
  kpiList.value.map((k) => ({
    label: `${k.name}${k.unit ? `(${k.unit})` : ''}`,
    value: k.code,
  })),
);
async function loadKpis() {
  try {
    const overview = await getKpiOverview();
    kpiList.value = overview.kpis || [];
    if (!selectedKpi.value && kpiList.value.length > 0) {
      selectedKpi.value = kpiList.value[0].code;
    }
  } catch {
    kpiList.value = [];
  }
}
async function handleGenerateKpiForecast() {
  if (!selectedKpi.value) {
    message.warning('请先选择 KPI');
    return;
  }
  generating.value = true;
  try {
    const count = await generateKpiForecast({
      kpiCode: selectedKpi.value,
      model: kpiModel.value,
      periods: forecastPeriods.value,
      intervalMinutes: intervalMinutes.value,
    });
    message.success(`已基于 KPI ç”Ÿæˆ ${count} æ¡é¢„测`);
    forecastCode.value = selectedKpi.value;
    await loadData();
  } catch (e) {
    const msg =
      typeof e === 'string'
        ? e
        : (e as Error | undefined)?.message || '暂无历史数据,无法预测';
    message.error(msg);
  } finally {
    generating.value = false;
  }
}
/** ========== ç”Ÿæˆå·¥å• ========== */
const [Form, formApi] = useVbenForm({
@@ -148,7 +229,7 @@
    if (!isOpen) {
      return;
    }
    const data = modalApi.getData<{ item: any }>();
    const data = modalApi.getData<{ item: DecisionForecastApi.ForecastItem }>();
    const item = data?.item;
    await formApi.resetForm();
    await formApi.setValues({
@@ -156,14 +237,18 @@
      workOrderType: 4,
      requestDate: item?.pointTime ?? dayjs().format('YYYY-MM-DD HH:mm:ss'),
      quantity: item?.forecastValue ?? 1,
      remark: `由负荷预测(${item?.forecastName ?? forecastCode.value})自动生成`,
      remark: `由预测(${item?.forecastName ?? forecastCode.value})自动生成`,
    });
  },
});
function handleGenerate(item: any) {
function handleGenerate(item: DecisionForecastApi.ForecastItem) {
  modalApi.setData({ item }).open();
}
onMounted(async () => {
  await Promise.all([loadData(), loadKpis()]);
});
</script>
<template>
@@ -171,20 +256,65 @@
    <Modal title="负荷预测生成生产工单" class="w-1/2">
      <Form class="mx-3" />
    </Modal>
    <div class="p-4">
      <div class="mb-4 flex items-center gap-4">
        <h2 class="text-lg font-bold">负荷预测</h2>
    <div class="p-4 space-y-4">
      <!-- å¤´éƒ¨ï¼šé¢„测编码 + æ¨¡åž‹ -->
      <div class="flex flex-wrap items-center gap-4">
        <h2 class="text-lg font-bold">预测分析</h2>
        <Select
          v-model:value="forecastCode"
          :options="FORECAST_OPTIONS"
          style="width: 180px"
          @change="loadData"
        />
        <Select v-model:value="model" :options="MODEL_OPTIONS" style="width: 200px" />
        <Button type="primary" :loading="generating" @click="handleGenerateForecast">
          ç”Ÿæˆé¢„测
        </Button>
      </div>
      <!-- KPI æ»šåŠ¨é¢„æµ‹ -->
      <div class="rounded-lg border border-gray-200 bg-white p-4">
        <div class="mb-3 text-sm font-medium">基于 KPI åŽ†å²æ»šåŠ¨é¢„æµ‹</div>
        <div class="grid grid-cols-2 gap-3 lg:grid-cols-5">
          <Select
            v-model:value="selectedKpi"
            :options="kpiOptions"
            style="width: 100%"
            placeholder="选择 KPI"
            show-search
            option-filter-prop="label"
          />
          <Select v-model:value="kpiModel" :options="MODEL_OPTIONS" style="width: 100%" />
          <div>
            <InputNumber
              v-model:value="forecastPeriods"
              class="!w-full"
              :min="1"
              :max="90"
              placeholder="预测期数"
            />
          </div>
          <div>
            <InputNumber
              v-model:value="intervalMinutes"
              class="!w-full"
              :min="60"
              :step="60"
              placeholder="间隔分钟"
            />
          </div>
          <Button :loading="generating" @click="handleGenerateKpiForecast">
            ç”Ÿæˆ KPI é¢„测
          </Button>
        </div>
        <div class="mt-2 text-xs text-gray-400">
          ä½¿ç”¨ KPI åŽ†å²å¿«ç…§æŒ‰æ‰€é€‰æ¨¡åž‹æ»šåŠ¨é¢„æµ‹ï¼Œé¢„æµ‹æœŸæ•°é»˜è®¤ 30、上限 90;间隔分钟默认 360(6 å°æ—¶ï¼‰ã€‚
        </div>
      </div>
      <Spin :spinning="loading">
        <div v-if="forecastData.length === 0" class="py-12 text-center text-gray-400">
          æš‚无预测数据,请先配置 KPI æŒ‡æ ‡å’Œé¢„警规则后查看
          æš‚无预测数据,请配置 KPI æŒ‡æ ‡å’Œé¢„警规则后查看
        </div>
        <div v-else class="grid grid-cols-1 gap-4 lg:grid-cols-2">
          <div
@@ -206,6 +336,9 @@
                ç½®ä¿¡åŒºé—´: [{{ item.lowerBound }} ~ {{ item.upperBound }}]
              </span>
            </div>
            <div v-if="item.modelVersion" class="mt-1 text-xs text-gray-400">
              æ¨¡åž‹: {{ item.modelVersion }}
            </div>
            <div v-if="item.dimension" class="mt-1 text-xs text-gray-400">
              {{ item.dimension }}: {{ item.dimensionValue }}
            </div>
@@ -224,4 +357,4 @@
      </Spin>
    </div>
  </Page>
</template>
</template>
src/views/bi/decision/kpi-config/data.ts
@@ -39,11 +39,12 @@
      componentProps: {
        options: [
          { label: '全部', value: '' },
          { label: '供电量', value: 'power_supply' },
          { label: '能耗', value: 'energy' },
          { label: '设备运行', value: 'device_operation' },
          { label: '生产', value: 'production' },
          { label: '质量', value: 'quality' },
          { label: '采购', value: 'procurement' },
          { label: '营销', value: 'sales' },
          { label: '安全', value: 'safety' },
        ],
        allowClear: true,
@@ -87,11 +88,12 @@
      component: 'Select',
      componentProps: {
        options: [
          { label: '供电量', value: 'power_supply' },
          { label: '能耗', value: 'energy' },
          { label: '设备运行', value: 'device_operation' },
          { label: '生产', value: 'production' },
          { label: '质量', value: 'quality' },
          { label: '采购', value: 'procurement' },
          { label: '营销', value: 'sales' },
          { label: '安全', value: 'safety' },
        ],
      },
@@ -145,10 +147,11 @@
}
export const CATEGORY_MAP: Record<string, string> = {
  power_supply: '供电量',
  energy: '能耗',
  device_operation: '设备运行',
  production: '生产',
  quality: '质量',
  procurement: '采购',
  sales: '营销',
  safety: '安全',
};
src/views/bi/decision/kpi-dashboard/index.vue
@@ -25,20 +25,22 @@
}
const CATEGORY_MAP: Record<string, string> = {
  power_supply: '供电量',
  energy: '能耗',
  device_operation: '设备运行',
  production: '生产',
  quality: '质量',
  procurement: '采购',
  sales: '营销',
  safety: '安全',
};
const CATEGORY_COLORS: Record<string, string> = {
  power_supply: '#1677ff',
  energy: '#f59e0b',
  device_operation: '#722ed1',
  production: '#13c2c2',
  quality: '#52c41a',
  procurement: '#fa8c16',
  sales: '#1677ff',
  safety: '#ff4d4f',
};
src/views/bi/decision/trend-analysis/index.vue
@@ -1,27 +1,56 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import type { EChartsOption, EchartsUIType } from '@vben/plugins/echarts';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Select, Spin, Table } from 'ant-design-vue';
import { Empty, Select, Spin, Table } from 'ant-design-vue';
import dayjs from 'dayjs';
import { getKpiOverview } from '#/api/bi/decision/kpi';
import {
  getKpiCompare,
  getKpiOverview,
  getKpiTrend,
} from '#/api/bi/decision/kpi';
import type { DecisionKpiApi } from '#/api/bi/decision/kpi';
defineOptions({ name: 'DecisionTrendAnalysis' });
const loading = ref(false);
const trendData = ref<any[]>([]);
const chartLoading = ref(false);
const kpiList = ref<DecisionKpiApi.KpiItem[]>([]);
const selectedCategory = ref<string>('');
const selectedKpi = ref<string>('');
const trend = ref<DecisionKpiApi.KpiTrend | null>(null);
const compare = ref<DecisionKpiApi.KpiCompare | null>(null);
const CATEGORY_OPTIONS = [
  { label: '全部', value: '' },
  { label: '供电量', value: 'power_supply' },
  { label: '能耗', value: 'energy' },
  { label: '设备运行', value: 'device_operation' },
  { label: '生产', value: 'production' },
  { label: '质量', value: 'quality' },
  { label: '采购', value: 'procurement' },
  { label: '营销', value: 'sales' },
  { label: '安全', value: 'safety' },
];
const kpiOptions = computed(() =>
  kpiList.value
    .filter((k) =>
      selectedCategory.value ? k.category === selectedCategory.value : true,
    )
    .map((k) => ({
      label: `${k.name}${k.unit ? `(${k.unit})` : ''}`,
      value: k.code,
    })),
);
const selectedKpiUnit = computed(
  () => kpiList.value.find((k) => k.code === selectedKpi.value)?.unit ?? '',
);
const columns = [
  { title: 'KPI名称', dataIndex: 'name', key: 'name', width: 150 },
@@ -31,43 +60,216 @@
  { title: '预警状态', dataIndex: 'alertStatus', key: 'alertStatus', width: 100 },
];
async function loadData() {
async function loadKpis() {
  loading.value = true;
  try {
    const overview = await getKpiOverview();
    let list = overview.kpis || [];
    if (selectedCategory.value) {
      list = list.filter((k) => k.category === selectedCategory.value);
    kpiList.value = overview.kpis || [];
    if (!selectedKpi.value && kpiOptions.value.length > 0) {
      selectedKpi.value = kpiOptions.value[0].value;
    }
    trendData.value = list;
  } catch {
    trendData.value = [];
    kpiList.value = [];
  } finally {
    loading.value = false;
  }
}
onMounted(loadData);
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
function formatTime(time: string): string {
  return dayjs(time).format('MM-DD HH:mm');
}
function buildChartOptions(t: DecisionKpiApi.KpiTrend): EChartsOption {
  const labels = t.points.map((p) => formatTime(p.time));
  const values = t.points.map((p) => Number(p.value) || 0);
  return {
    backgroundColor: 'transparent',
    grid: { bottom: 36, left: 12, right: 24, top: 28, containLabel: true },
    tooltip: {
      trigger: 'axis',
      backgroundColor: 'rgba(255,255,255,0.96)',
      borderColor: '#e5e7eb',
      textStyle: { color: '#1f2937', fontSize: 12 },
      axisPointer: { type: 'cross' },
    },
    xAxis: {
      type: 'category',
      boundaryGap: false,
      data: labels,
      axisLabel: { color: '#6b7280', fontSize: 11 },
      axisLine: { lineStyle: { color: '#e5e7eb' } },
      axisTick: { show: false },
    },
    yAxis: {
      type: 'value',
      name: t.unit || '',
      nameTextStyle: { color: '#9ca3af', fontSize: 11 },
      axisLabel: { color: '#6b7280', fontSize: 11 },
      splitLine: { lineStyle: { color: '#f0f1f3', type: 'dashed' } },
    },
    series: [
      {
        name: t.kpiName,
        type: 'line',
        data: values,
        smooth: true,
        symbol: 'circle',
        symbolSize: 4,
        lineStyle: { width: 2, color: '#1677ff' },
        itemStyle: { color: '#1677ff' },
        areaStyle: {
          color: {
            type: 'linear',
            x: 0, y: 0, x2: 0, y2: 1,
            colorStops: [
              { offset: 0, color: 'rgba(22,119,255,0.18)' },
              { offset: 1, color: 'rgba(22,119,255,0.01)' },
            ],
          },
        },
      },
    ],
  };
}
async function loadTrendDetail() {
  if (!selectedKpi.value) {
    trend.value = null;
    compare.value = null;
    return;
  }
  chartLoading.value = true;
  try {
    const [t, c] = await Promise.all([
      getKpiTrend(selectedKpi.value),
      getKpiCompare(selectedKpi.value),
    ]);
    trend.value = t;
    compare.value = c;
    await renderEcharts(buildChartOptions(t));
  } catch {
    trend.value = null;
    compare.value = null;
  } finally {
    chartLoading.value = false;
  }
}
function formatCompareValue(v: number | undefined): string {
  if (v == null || Number.isNaN(v)) return '--';
  return Number.isInteger(v) ? v.toLocaleString() : Number(v).toFixed(2);
}
const changeRateText = computed(() => {
  const rate = compare.value?.changeRate;
  if (rate == null || Number.isNaN(rate)) return '--';
  const up = rate >= 0;
  return `${up ? '↑' : '↓'} ${Math.abs(rate).toFixed(2)}%`;
});
const changeRateColor = computed(() => {
  const rate = compare.value?.changeRate;
  if (rate == null || Number.isNaN(rate)) return '#6b7280';
  return rate >= 0 ? '#22c55e' : '#ef4444';
});
watch(selectedCategory, () => {
  const first = kpiOptions.value[0];
  selectedKpi.value = first?.value ?? '';
});
watch(selectedKpi, loadTrendDetail);
onMounted(loadKpis);
onBeforeUnmount(() => {
  chartRef.value = undefined;
});
</script>
<template>
  <Page :auto-content-height="true">
    <div class="p-4">
      <div class="mb-4 flex items-center gap-4">
      <div class="mb-4 grid grid-cols-1 gap-3 lg:grid-cols-[auto_auto_1fr]">
        <h2 class="text-lg font-bold">趋势分析</h2>
        <Select
          v-model:value="selectedCategory"
          :options="CATEGORY_OPTIONS"
          style="width: 160px"
          allow-clear
          @change="loadData"
        />
        <Select
          v-model:value="selectedKpi"
          :options="kpiOptions"
          style="width: 280px"
          placeholder="请选择 KPI"
          show-search
          option-filter-prop="label"
        />
      </div>
      <!-- çŽ¯æ¯”ç ”åˆ¤å¡ç‰‡ -->
      <Spin :spinning="chartLoading">
        <div v-if="selectedKpi" class="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
          <div class="rounded-lg border border-gray-200 p-4">
            <div class="text-xs text-gray-500">当前值</div>
            <div class="mt-1 text-2xl font-bold text-gray-800">
              {{ formatCompareValue(compare?.currentValue) }}
              <span class="ml-1 text-sm font-normal text-gray-400">{{ selectedKpiUnit }}</span>
            </div>
            <div v-if="compare?.currentTime" class="mt-1 text-xs text-gray-400">
              {{ formatTime(compare.currentTime) }}
            </div>
          </div>
          <div class="rounded-lg border border-gray-200 p-4">
            <div class="text-xs text-gray-500">环比变化率</div>
            <div class="mt-1 text-2xl font-bold" :style="{ color: changeRateColor }">
              {{ changeRateText }}
            </div>
            <div v-if="compare?.previousTime" class="mt-1 text-xs text-gray-400">
              ä¸ŠæœŸ {{ formatTime(compare.previousTime) }}
            </div>
          </div>
          <div class="rounded-lg border border-gray-200 p-4">
            <div class="text-xs text-gray-500">近24期均值</div>
            <div class="mt-1 text-2xl font-bold text-gray-800">
              {{ formatCompareValue(compare?.avgValue) }}
              <span class="ml-1 text-sm font-normal text-gray-400">{{ selectedKpiUnit }}</span>
            </div>
            <div class="mt-1 text-xs text-gray-400">已统计 {{ compare?.periodCount ?? 0 }} æœŸ</div>
          </div>
          <div class="rounded-lg border border-gray-200 p-4">
            <div class="text-xs text-gray-500">峰值 / æ³¢è°·</div>
            <div class="mt-1 text-2xl font-bold text-gray-800">
              {{ formatCompareValue(compare?.maxValue) }}
              <span class="text-sm font-normal text-gray-400">/</span>
              {{ formatCompareValue(compare?.minValue) }}
              <span class="ml-1 text-sm font-normal text-gray-400">{{ selectedKpiUnit }}</span>
            </div>
          </div>
        </div>
      </Spin>
      <!-- è¶‹åŠ¿æŠ˜çº¿å›¾ -->
      <div class="mb-4 rounded-lg border border-gray-200 bg-white p-4">
        <div class="mb-2 flex items-center justify-between">
          <span class="font-medium">{{ trend?.kpiName ?? selectedKpi }}</span>
          <span v-if="trend" class="text-xs text-gray-400">
            {{ trend.periodType }} å‘¨æœŸ Â· å…± {{ trend.points.length }} ä¸ªå¿«ç…§ç‚¹
          </span>
        </div>
        <EchartsUI v-if="trend && trend.points.length > 0" ref="chartRef" class="!h-72 w-full" />
        <Empty v-else description="暂无历史快照数据,KPI å®šä¹‰åŽä»Žä¸‹ä¸€ä¸ªæ•´ç‚¹å¼€å§‹èšåˆ" />
      </div>
      <Spin :spinning="loading">
        <Table
          :columns="columns"
          :data-source="trendData"
          :data-source="kpiList.filter((k) =>
            selectedCategory ? k.category === selectedCategory : true,
          )"
          :pagination="{ pageSize: 20 }"
          row-key="code"
          bordered
src/views/hrm/training/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,243 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmTrainingApi } from '#/api/hrm/training';
/** åŸ¹è®­ç±»åž‹é€‰é¡¹ */
export const trainingTypeOptions = [
  { label: '安全培训', value: 1 },
  { label: '技能培训', value: 2 },
  { label: '管理培训', value: 3 },
  { label: '其他', value: 4 },
];
/** åŸ¹è®­çŠ¶æ€é€‰é¡¹ */
export const trainingStatusOptions = [
  { label: '草稿', value: 0 },
  { label: '待开展', value: 10 },
  { label: '已完成', value: 20 },
  { label: '已取消', value: 30 },
];
/** è¡¨å•类型 */
export type FormType = 'create' | 'edit' | 'detail';
/** è¡¨å•的配置项 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'name',
      label: '培训名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入培训名称',
      },
      rules: 'required',
    },
    {
      fieldName: 'trainingType',
      label: '培训类型',
      component: 'Select',
      componentProps: {
        options: trainingTypeOptions,
        placeholder: '请选择培训类型',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'trainer',
      label: '讲师',
      component: 'Input',
      componentProps: {
        placeholder: '请输入讲师',
      },
    },
    {
      fieldName: 'location',
      label: '培训地点',
      component: 'Input',
      componentProps: {
        placeholder: '请输入培训地点',
      },
    },
    {
      fieldName: 'hours',
      label: '培训学时',
      component: 'InputNumber',
      componentProps: {
        placeholder: '请输入培训学时',
        min: 0,
        precision: 1,
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'startDate',
      label: '开始日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '选择开始日期',
        valueFormat: 'YYYY-MM-DD',
      },
      rules: 'required',
    },
    {
      fieldName: 'endDate',
      label: '结束日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '选择结束日期',
        valueFormat: 'YYYY-MM-DD',
      },
      rules: 'required',
    },
    {
      fieldName: 'issueCertificate',
      label: '自动发证',
      component: 'Select',
      componentProps: {
        options: [
          { label: '是', value: 1 },
          { label: '否', value: 0 },
        ],
        placeholder: '是否自动发证',
      },
    },
    {
      fieldName: 'certValidityMonths',
      label: '证书有效期(月)',
      component: 'InputNumber',
      componentProps: {
        placeholder: '自动发证时填写',
        min: 0,
        precision: 0,
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 1, maxRows: 2 },
      },
      formItemClass: 'col-span-2',
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'trainingNo',
      label: '培训编号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入培训编号',
      },
    },
    {
      fieldName: 'name',
      label: '培训名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入培训名称',
      },
    },
    {
      fieldName: 'trainingType',
      label: '培训类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: trainingTypeOptions,
        placeholder: '请选择培训类型',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: trainingStatusOptions,
        placeholder: '请选择状态',
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<HrmTrainingApi.Training>['columns'] {
  return [
    {
      field: 'trainingNo',
      title: '培训编号',
      width: 140,
    },
    {
      field: 'name',
      title: '培训名称',
      minWidth: 180,
    },
    {
      field: 'trainingTypeName',
      title: '培训类型',
      width: 100,
    },
    {
      field: 'trainer',
      title: '讲师',
      width: 100,
    },
    {
      field: 'location',
      title: '培训地点',
      minWidth: 120,
    },
    {
      field: 'hours',
      title: '学时',
      width: 80,
    },
    {
      field: 'startDate',
      title: '开始日期',
      width: 105,
      formatter: 'formatDate',
    },
    {
      field: 'endDate',
      title: '结束日期',
      width: 105,
      formatter: 'formatDate',
    },
    {
      field: 'participantCount',
      title: '参训人数',
      width: 90,
    },
    {
      field: 'statusName',
      title: '状态',
      width: 90,
      slots: { default: 'status' },
    },
    {
      field: 'action',
      title: '操作',
      width: 240,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/hrm/training/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,263 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmTrainingApi } from '#/api/hrm/training';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message, Modal, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  cancelTraining,
  completeTraining,
  deleteTraining,
  getTrainingPage,
  startTraining,
} from '#/api/hrm/training';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import Participants from './modules/participants.vue';
/** HRM åŸ¹è®­ç®¡ç†åˆ—表 */
defineOptions({ name: 'HrmTraining' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ParticipantsModal, participantsModalApi] = useVbenModal({
  connectedComponent: Participants,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleEdit(row: HrmTrainingApi.Training) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
function handleDetail(row: HrmTrainingApi.Training) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
function handleParticipants(row: HrmTrainingApi.Training) {
  participantsModalApi.setData({ trainingId: row.id }).open();
}
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await deleteTraining(ids);
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** å¼€å§‹åŸ¹è®­ï¼šè‰ç¨¿ â†’ å¾…开展 */
function handleStart(row: HrmTrainingApi.Training) {
  Modal.confirm({
    title: '开始培训',
    content: `是否开始培训「${row.name}」?`,
    onOk: async () => {
      await startTraining(row.id!);
      message.success('开始培训成功');
      handleRefresh();
    },
  });
}
/** å®ŒæˆåŸ¹è®­ï¼šè‰ç¨¿/待开展 â†’ å·²å®Œæˆ */
function handleComplete(row: HrmTrainingApi.Training) {
  Modal.confirm({
    title: '完成培训',
    content: `是否完成培训「${row.name}」?`,
    onOk: async () => {
      await completeTraining(row.id!);
      message.success('完成培训成功');
      handleRefresh();
    },
  });
}
/** å–消培训:草稿/待开展 â†’ å·²å–消 */
function handleCancel(row: HrmTrainingApi.Training) {
  Modal.confirm({
    title: '取消培训',
    content: `是否取消培训「${row.name}」?`,
    onOk: async () => {
      await cancelTraining(row.id!);
      message.success('取消培训成功');
      handleRefresh();
    },
  });
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: HrmTrainingApi.Training[];
}) {
  checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getTrainingPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<HrmTrainingApi.Training>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ParticipantsModal @success="handleRefresh" />
    <Grid table-title="培训管理列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['培训']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['hrm:training:create'],
              onClick: handleCreate,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['hrm:training:delete'],
              popConfirm: {
                title: '是否删除所选中数据?',
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #status="{ row }">
        <Tag v-if="row.status === 0" color="default">草稿</Tag>
        <Tag v-else-if="row.status === 10" color="processing">待开展</Tag>
        <Tag v-else-if="row.status === 20" color="success">已完成</Tag>
        <Tag v-else-if="row.status === 30" color="error">已取消</Tag>
        <Tag v-else color="default">-</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['hrm:training:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: '编辑',
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['hrm:training:update'],
              ifShow: () => row.status === 0,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '参训人员',
              type: 'link',
              icon: 'lucide:users',
              auth: ['hrm:training:update'],
              ifShow: () => row.status === 0 || row.status === 10 || row.status === 20,
              onClick: handleParticipants.bind(null, row),
            },
            {
              label: '开始',
              type: 'link',
              icon: 'lucide:play',
              auth: ['hrm:training:update'],
              ifShow: () => row.status === 0,
              onClick: handleStart.bind(null, row),
            },
            {
              label: '完成',
              type: 'link',
              icon: 'lucide:check',
              auth: ['hrm:training:update'],
              ifShow: () => row.status === 0 || row.status === 10,
              onClick: handleComplete.bind(null, row),
            },
            {
              label: '取消',
              type: 'link',
              danger: true,
              icon: ACTION_ICON.CLOSE,
              auth: ['hrm:training:update'],
              ifShow: () => row.status === 0 || row.status === 10,
              popConfirm: {
                title: '是否取消培训?',
                confirm: handleCancel.bind(null, row),
              },
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['hrm:training:delete'],
              ifShow: () => row.status === 0,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.name]),
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/hrm/training/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
<script lang="ts" setup>
import type { HrmTrainingApi, ReqTraining } from '#/api/hrm/training';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createTraining, getTraining, updateTraining } from '#/api/hrm/training';
import { $t } from '#/locales';
import type { FormType } from '../data';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<HrmTrainingApi.Training>();
const formType = ref<FormType>('create');
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return $t('ui.actionTitle.create', ['培训']);
  } else if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['培训']);
  }
  return '培训详情';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-3',
  layout: 'vertical',
  schema: useFormSchema(formType.value),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const values = await formApi.getValues<ReqTraining>();
    // è‡ªåŠ¨å‘è¯å­—æ®µï¼šè¡¨å•ç”¨ 0/1 ä¸‹æ‹‰ï¼Œæäº¤æ—¶è½¬å¸ƒå°”
    const data = {
      ...values,
      issueCertificate: Boolean(values.issueCertificate),
    } as ReqTraining;
    try {
      await (formType.value === 'create' ? createTraining(data) : updateTraining(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setDisabled(formType.value === 'detail');
    formApi.updateSchema(useFormSchema(formType.value));
    if (!data || !data.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getTraining(data.id);
      await formApi.setValues({
        ...formData.value,
        // å¸ƒå°” â†’ 0/1 ä¸‹æ‹‰å€¼
        issueCertificate: formData.value.issueCertificate ? 1 : 0,
      });
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-2/3" :show-confirm-button="formType !== 'detail'">
    <Form class="mx-3" />
  </Modal>
</template>
src/views/hrm/training/modules/participants.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,278 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmEmployeeApi } from '#/api/hrm/employee';
import type { HrmTrainingApi } from '#/api/hrm/training';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Modal as AntModal, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  addTrainingParticipants,
  deleteTrainingParticipants,
  getTrainingParticipants,
  updateTrainingParticipant,
} from '#/api/hrm/training';
import { $t } from '#/locales';
import { HrmEmployeeSelectDialog } from '#/views/hrm/employee/components';
defineOptions({ name: 'HrmTrainingParticipants' });
const emit = defineEmits(['success']);
const trainingId = ref<number>();
/** å‚训人员编辑表单 */
const participantFormData = ref<HrmTrainingApi.Participant>();
const editVisible = ref(false);
const editSaving = ref(false);
const [EditForm, editFormApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'attended',
      label: '是否参训',
      component: 'Select',
      componentProps: {
        options: [
          { label: '是', value: 1 },
          { label: '否', value: 0 },
        ],
        placeholder: '是否参训',
      },
    },
    {
      fieldName: 'score',
      label: '成绩',
      component: 'InputNumber',
      componentProps: {
        placeholder: '请输入成绩',
        min: 0,
        precision: 1,
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'isPass',
      label: '是否合格',
      component: 'Select',
      componentProps: {
        options: [
          { label: '是', value: 1 },
          { label: '否', value: 0 },
        ],
        placeholder: '是否合格',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 1, maxRows: 2 },
      },
      formItemClass: 'col-span-2',
    },
  ],
  showDefaultActions: false,
});
/** æ‰“开编辑弹窗 */
function openEditForm(row: HrmTrainingApi.Participant) {
  participantFormData.value = row;
  editFormApi.setValues({
    id: row.id,
    attended: row.attended ? 1 : 0,
    score: row.score,
    isPass: row.isPass ? 1 : 0,
    remark: row.remark,
  });
  editVisible.value = true;
}
/** ä¿å­˜å‚训记录 */
async function handleEditSave() {
  const { valid } = await editFormApi.validate();
  if (!valid) {
    return;
  }
  editSaving.value = true;
  const values = await editFormApi.getValues<HrmTrainingApi.Participant>();
  try {
    await updateTrainingParticipant({
      id: values.id,
      attended: Boolean(values.attended),
      score: values.score,
      isPass: Boolean(values.isPass),
      remark: values.remark,
    } as HrmTrainingApi.Participant);
    message.success($t('ui.actionMessage.operationSuccess'));
    editVisible.value = false;
    await loadParticipants();
  } finally {
    editSaving.value = false;
  }
}
/** åˆ é™¤å‚训记录 */
async function handleDelete(row: HrmTrainingApi.Participant) {
  await deleteTrainingParticipants([row.id!]);
  message.success($t('ui.actionMessage.deleteSuccess'));
  await loadParticipants();
}
/** å‘˜å·¥é€‰æ‹©å¼¹çª—(多选) */
const employeeDialogRef = ref<InstanceType<typeof HrmEmployeeSelectDialog>>();
function openEmployeeSelect() {
  employeeDialogRef.value?.open([], { multiple: true });
}
async function handleEmployeesSelected(rows: HrmEmployeeApi.Employee[]) {
  const employeeIds = rows
    .map((item) => item.id)
    .filter((id): id is number => id !== undefined && id !== null);
  if (employeeIds.length === 0) {
    return;
  }
  try {
    await addTrainingParticipants(trainingId.value!, employeeIds);
    message.success('添加参训人员成功');
    await loadParticipants();
    emit('success');
  } catch (e) {
    // åŽç«¯å…œåº•提示已弹出
  }
}
async function loadParticipants() {
  if (!trainingId.value) {
    return;
  }
  const list = await getTrainingParticipants(trainingId.value);
  gridApi.grid.loadData(list);
}
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: [
      { type: 'seq', title: '序号', width: 60 },
      { field: 'employeeNo', title: '员工编号', width: 130 },
      { field: 'employeeName', title: '员工姓名', minWidth: 120 },
      {
        field: 'attended',
        title: '是否参训',
        width: 100,
        slots: { default: 'attended' },
      },
      {
        field: 'score',
        title: '成绩',
        width: 90,
      },
      {
        field: 'isPass',
        title: '是否合格',
        width: 100,
        slots: { default: 'isPass' },
      },
      { field: 'remark', title: '备注', minWidth: 120 },
      {
        field: 'action',
        title: '操作',
        width: 140,
        fixed: 'right',
        slots: { default: 'actions' },
      },
    ],
    height: 'auto',
    keepSource: true,
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: false,
      search: false,
    },
  } as VxeTableGridOptions<HrmTrainingApi.Participant>,
});
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen) {
    if (!isOpen) {
      trainingId.value = undefined;
      return;
    }
    const data = modalApi.getData<{ trainingId?: number }>();
    trainingId.value = data?.trainingId;
    if (trainingId.value) {
      loadParticipants();
    }
  },
});
</script>
<template>
  <Modal title="参训人员管理" class="w-[900px]" :show-confirm-button="false">
    <HrmEmployeeSelectDialog
      ref="employeeDialogRef"
      @selected="handleEmployeesSelected"
    />
    <Grid table-title="参训人员列表">
      <template #toolbar-tools>
        <a-button type="primary" @click="openEmployeeSelect">
          <i-ant-design-plus-outlined class="mr-1" />添加参训人员
        </a-button>
      </template>
      <template #attended="{ row }">
        <Tag v-if="row.attended" color="success">是</Tag>
        <Tag v-else color="default">否</Tag>
      </template>
      <template #isPass="{ row }">
        <Tag v-if="row.isPass" color="success">合格</Tag>
        <Tag v-else-if="row.attended" color="error">不合格</Tag>
        <Tag v-else color="default">-</Tag>
      </template>
      <template #actions="{ row }">
        <a-button type="link" size="small" @click="openEditForm(row)">编辑</a-button>
        <a-popconfirm
          :title="$t('ui.actionMessage.deleteConfirm', [row.employeeName ?? row.id])"
          @confirm="handleDelete(row)"
        >
          <a-button type="link" danger size="small">删除</a-button>
        </a-popconfirm>
      </template>
    </Grid>
    <AntModal
      v-model:open="editVisible"
      title="编辑参训记录"
      width="480px"
      :confirm-loading="editSaving"
      @ok="handleEditSave"
    >
      <EditForm class="pt-4" />
    </AntModal>
  </Modal>
</template>
src/views/hrm/workHourSummary/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,107 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmWorkHourSummaryApi } from '#/api/hrm/work-hour-summary';
import { getDeptList } from '#/api/system/dept';
import { getEmployeeSimpleList } from '#/api/hrm/employee';
import { handleTree } from '#/packages/utils/src';
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'period',
      label: '核算周期',
      component: 'DatePicker',
      componentProps: {
        picker: 'month',
        allowClear: true,
        format: 'YYYY-MM',
        valueFormat: 'YYYY-MM',
        style: { width: '100%' },
        placeholder: '请选择核算周期',
      },
    },
    {
      fieldName: 'employeeId',
      label: '员工',
      component: 'ApiSelect',
      componentProps: {
        api: getEmployeeSimpleList,
        labelField: 'name',
        valueField: 'id',
        allowClear: true,
        placeholder: '请选择员工',
      },
    },
    {
      fieldName: 'deptId',
      label: '部门',
      component: 'ApiTreeSelect',
      componentProps: {
        api: async () => {
          const data = await getDeptList();
          return handleTree(data);
        },
        labelField: 'name',
        valueField: 'id',
        childrenField: 'children',
        allowClear: true,
        placeholder: '请选择部门',
        treeDefaultExpandAll: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<HrmWorkHourSummaryApi.WorkHourSummary>['columns'] {
  return [
    {
      field: 'period',
      title: '核算周期',
      width: 100,
    },
    {
      field: 'employeeNo',
      title: '员工编号',
      width: 130,
    },
    {
      field: 'employeeName',
      title: '员工姓名',
      minWidth: 110,
    },
    {
      field: 'deptName',
      title: '部门',
      minWidth: 120,
    },
    {
      field: 'workDays',
      title: '出勤天数',
      width: 90,
    },
    {
      field: 'workHours',
      title: '正常工时(h)',
      width: 110,
    },
    {
      field: 'overtimeHours',
      title: '加班工时(h)',
      width: 110,
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 120,
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 100,
      formatter: 'formatDate',
    },
  ];
}
src/views/hrm/workHourSummary/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,128 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmWorkHourSummaryApi } from '#/api/hrm/work-hour-summary';
import { ref } from 'vue';
import { Page } from '#/packages/effects/common-ui/src';
import { message, DatePicker, Form, FormItem, Modal } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  generateWorkHourSummary,
  getWorkHourSummaryPage,
} from '#/api/hrm/work-hour-summary';
import { useGridColumns, useGridFormSchema } from './data';
/** HRM æœˆåº¦å·¥æ—¶æ±‡æ€» */
defineOptions({ name: 'HrmWorkHourSummary' });
// ç”Ÿæˆæœˆåº¦æ±‡æ€»å¼¹çª—
const generateVisible = ref(false);
const generatePeriod = ref<string>();
const generateLoading = ref(false);
/** æ‰“开生成弹窗,带出搜索条件中的核算周期 */
function openGenerateModal() {
  void gridApi.formApi.getValues().then((values) => {
    generatePeriod.value = values.period;
  });
  generateVisible.value = true;
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ‰§è¡Œç”Ÿæˆ */
async function handleGenerate() {
  if (!generatePeriod.value) {
    message.warning('请选择核算周期');
    return;
  }
  generateLoading.value = true;
  try {
    await generateWorkHourSummary(generatePeriod.value);
    message.success('月度工时汇总生成成功');
    generateVisible.value = false;
    handleRefresh();
  } finally {
    generateLoading.value = false;
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getWorkHourSummaryPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<HrmWorkHourSummaryApi.WorkHourSummary>,
});
</script>
<template>
  <Page auto-content-height>
    <!-- ç”Ÿæˆæœˆåº¦å·¥æ—¶æ±‡æ€»å¼¹çª— -->
    <Modal
      v-model:open="generateVisible"
      title="生成月度工时汇总"
      width="420px"
      :confirm-loading="generateLoading"
      @ok="handleGenerate"
    >
      <Form layout="vertical" class="pt-4">
        <FormItem label="核算周期" required>
          <DatePicker
            v-model:value="generatePeriod"
            picker="month"
            format="YYYY-MM"
            valueFormat="YYYY-MM"
            placeholder="请选择核算周期"
            style="width: 100%"
          />
        </FormItem>
      </Form>
    </Modal>
    <Grid table-title="月度工时汇总列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '生成月度汇总',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['hrm:work-hour:generate'],
              onClick: openGenerateModal,
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/wls/barcode/components/index.ts
@@ -1,3 +1,4 @@
export { default as Barcode } from './barcode.vue';
export { default as BarcodeDetail } from './detail.vue';
export { default as PrinterLabel } from './printer-label.vue';
export { default as ScanModal } from './scan-modal.vue';
src/views/wls/barcode/components/scan-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,178 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import {
  Button,
  Descriptions,
  Divider,
  Empty,
  Image,
  Input,
  message,
  Modal,
  Tag,
} from 'ant-design-vue';
import {
  parseBarcodeContent,
  renderBarcodeImage,
} from '#/api/mes/wm/barcode';
import { DictTag } from '#/components/dict-tag';
defineOptions({ name: 'WlsBarcodeScanModal' });
const open = ref(false);
const content = ref('');
const parseResult = ref<Record<string, any>>();
const imageBase64 = ref('');
const parsing = ref(false);
const rendering = ref(false);
function openModal() {
  open.value = true;
  content.value = '';
  parseResult.value = undefined;
  imageBase64.value = '';
}
/** è§£æžæ¡ç å†…容 */
async function handleParse() {
  if (!content.value?.trim()) {
    message.warning('请先扫码枪扫入或手动输入条码内容');
    return;
  }
  parsing.value = true;
  try {
    parseResult.value = await parseBarcodeContent(content.value.trim());
  } finally {
    parsing.value = false;
  }
}
/** æœåŠ¡ç«¯æˆå›¾é¢„è§ˆï¼ˆPNG Base64,不落库) */
async function handleRender() {
  if (!content.value?.trim()) {
    message.warning('请先扫码枪扫入或手动输入条码内容');
    return;
  }
  rendering.value = true;
  try {
    const base64 = await renderBarcodeImage(
      content.value.trim(),
      parseResult.value?.format,
    );
    imageBase64.value = base64 ? `data:image/png;base64,${base64}` : '';
    if (!base64) {
      message.warning('成图失败,请检查条码内容是否符合所选格式');
    }
  } finally {
    rendering.value = false;
  }
}
/** åŒ¹é…æ–¹å¼æ–‡æ¡ˆ */
const matchedLabel = computed(() => {
  if (parseResult.value?.matched) {
    return '精确命中';
  }
  if (parseResult.value?.fields) {
    return '模板反解';
  }
  return '未匹配';
});
const matchedColor = computed(() => {
  if (parseResult.value?.matched) {
    return 'green';
  }
  if (parseResult.value?.fields) {
    return 'blue';
  }
  return 'orange';
});
defineExpose({ open: openModal });
</script>
<template>
  <Modal v-model:open="open" title="扫码解析 / æ¡ç é¢„览" width="720px">
    <div class="space-y-4">
      <div class="flex gap-2">
        <Input
          v-model:value="content"
          placeholder="扫码枪扫入或手动输入条码内容,回车解析"
          @press-enter="handleParse"
        />
        <Button type="primary" :loading="parsing" @click="handleParse">
          è§£æž
        </Button>
        <Button :loading="rendering" @click="handleRender">成图预览</Button>
      </div>
      <div v-if="parseResult">
        <Divider orientation="left">解析结果</Divider>
        <Descriptions
          v-if="parseResult.bizType != null || parseResult.bizCode != null"
          bordered
          :column="2"
          size="small"
        >
          <Descriptions.Item label="匹配方式">
            <Tag :color="matchedColor">{{ matchedLabel }}</Tag>
          </Descriptions.Item>
          <Descriptions.Item v-if="parseResult.format != null" label="条码格式">
            <DictTag
              :type="DICT_TYPE.MES_WM_BARCODE_FORMAT"
              :value="parseResult.format"
            />
          </Descriptions.Item>
          <Descriptions.Item v-if="parseResult.bizType != null" label="业务类型">
            <DictTag
              :type="DICT_TYPE.MES_WM_BARCODE_BIZ_TYPE"
              :value="parseResult.bizType"
            />
          </Descriptions.Item>
          <Descriptions.Item v-if="parseResult.barcodeId != null" label="条码编号">
            {{ parseResult.barcodeId }}
          </Descriptions.Item>
          <Descriptions.Item label="业务编码">
            {{ parseResult.bizCode || '-' }}
          </Descriptions.Item>
          <Descriptions.Item label="业务名称">
            {{ parseResult.bizName || '-' }}
          </Descriptions.Item>
          <Descriptions.Item v-if="parseResult.bizId != null" label="业务编号">
            {{ parseResult.bizId }}
          </Descriptions.Item>
        </Descriptions>
        <div v-if="parseResult.fields" class="mt-2">
          <div class="mb-1 text-sm font-medium">模板字段</div>
          <div
            v-for="(value, key) in parseResult.fields"
            :key="key"
            class="mb-1 flex items-center gap-2"
          >
            <Tag>{{ key }}</Tag>
            <span>{{ value }}</span>
          </div>
        </div>
        <Empty
          v-else-if="parseResult.bizType == null && parseResult.bizCode == null"
          description="未匹配到条码记录或条码配置模板"
        />
      </div>
      <div v-if="imageBase64">
        <Divider orientation="left">成图预览</Divider>
        <div class="flex justify-center rounded bg-gray-100 p-4">
          <Image :src="imageBase64" :width="220" />
        </div>
      </div>
    </div>
    <template #footer>
      <Button @click="open = false">关闭</Button>
    </template>
  </Modal>
</template>
src/views/wls/barcode/index.vue
@@ -18,7 +18,7 @@
} from '#/api/mes/wm/barcode';
import { $t } from '#/locales';
import { Barcode, BarcodeDetail } from './components';
import { Barcode, BarcodeDetail, ScanModal } from './components';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
@@ -30,6 +30,12 @@
});
const barcodeDetailRef = ref<InstanceType<typeof BarcodeDetail>>();
const scanModalRef = ref<InstanceType<typeof ScanModal>>();
/** æ‰«ç è§£æž / æˆå›¾é¢„览 */
function handleScan() {
  scanModalRef.value?.open();
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
@@ -145,12 +151,20 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <BarcodeDetail ref="barcodeDetailRef" />
    <ScanModal ref="scanModalRef" />
    <Grid table-title="条码列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '扫码解析',
              type: 'primary',
              icon: ACTION_ICON.SCAN,
              auth: ['mes:wm-barcode:query'],
              onClick: handleScan,
            },
            {
              label: $t('ui.actionTitle.create', ['条码']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
src/views/wls/components/process-select-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Select } from 'ant-design-vue';
interface ProcessDefinition {
  id: string;
  name: string;
  key: string;
}
type ProcessSubmitFn = (id: number, processDefinitionKey: string) => Promise<unknown>;
const emit = defineEmits(['success']);
const bizId = ref<number>();
const processList = ref<ProcessDefinition[]>([]);
const selectedProcessKey = ref<string>('');
const processOptions = computed(() => {
  return processList.value.map((p) => ({
    label: p.name,
    value: p.key,
  }));
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!selectedProcessKey.value) {
      message.warning('请选择审批流程');
      return;
    }
    modalApi.lock();
    try {
      await modalApi.getData<{ submitFn: ProcessSubmitFn }>().submitFn(
        bizId.value!,
        selectedProcessKey.value,
      );
      await modalApi.close();
      emit('success');
      message.success('提交审批成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      bizId.value = undefined;
      selectedProcessKey.value = '';
      processList.value = [];
      return;
    }
    const data = modalApi.getData<{
      id: number;
      processList: ProcessDefinition[];
      submitFn: ProcessSubmitFn;
    }>();
    bizId.value = data.id;
    processList.value = data.processList || [];
    // é»˜è®¤é€‰æ‹©ç¬¬ä¸€ä¸ªæµç¨‹
    const first = processList.value[0];
    if (first) {
      selectedProcessKey.value = first.key;
    }
  },
});
</script>
<template>
  <Modal title="选择审批流程" class="w-1/3">
    <div class="mb-4">
      <span class="mr-2">审批流程:</span>
      <Select
        v-model:value="selectedProcessKey"
        :options="processOptions"
        style="width: 200px"
        placeholder="请选择审批流程"
      />
    </div>
  </Modal>
</template>
src/views/wls/equipmentreceipt/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,342 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmEquipmentReceiptApi } from '#/api/mes/wm/equipmentreceipt';
import type { MesWmEquipmentReceiptLineApi } from '#/api/mes/wm/equipmentreceipt/line';
import { h, markRaw } from 'vue';
import { MesAutoCodeRuleCode, MesWmEquipmentReceiptStatusEnum } from '@vben/constants';
import { Button } from 'ant-design-vue';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getRangePickerDefaultProps } from '#/utils';
import { DvMachinerySelect } from '#/views/mes/dv/machinery/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'finish' | 'update';
/** è¡¨å•头部是否只读(详情、执行入库态) */
function isHeaderReadonly(formType: FormType): boolean {
  return formType === 'detail' || formType === 'finish';
}
/** è®¾å¤‡å…¥åº“单状态展示映射 */
export const EQUIPMENT_RECEIPT_STATUS_MAP: Record<
  number,
  { text: string; color: string }
> = {
  [MesWmEquipmentReceiptStatusEnum.PREPARE]: {
    text: '草稿',
    color: 'default',
  },
  [MesWmEquipmentReceiptStatusEnum.APPROVING]: {
    text: '审批中',
    color: 'processing',
  },
  [MesWmEquipmentReceiptStatusEnum.APPROVED]: {
    text: '已审批',
    color: 'success',
  },
  [MesWmEquipmentReceiptStatusEnum.FINISHED]: {
    text: '已完成',
    color: 'cyan',
  },
  [MesWmEquipmentReceiptStatusEnum.CANCELED]: {
    text: '已取消',
    color: 'warning',
  },
};
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(
  formType: FormType,
  formApi?: VbenFormApi,
): VbenFormSchema[] {
  const headerReadonly = isHeaderReadonly(formType);
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'status',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'code',
      label: '单据编码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入单据编码',
      },
      rules: 'required',
      suffix: headerReadonly
        ? undefined
        : () =>
            h(
              Button,
              {
                type: 'default',
                onClick: async () => {
                  const code = await generateAutoCode(
                    MesAutoCodeRuleCode.WM_EQUIP_RECEIPT_CODE,
                  );
                  await formApi?.setFieldValue('code', code);
                },
              },
              { default: () => '生成' },
            ),
    },
    {
      fieldName: 'name',
      label: '单据名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入单据名称',
      },
      rules: 'required',
    },
    {
      fieldName: 'receiptDate',
      label: '入库日期',
      component: 'DatePicker',
      componentProps: {
        format: 'YYYY-MM-DD',
        placeholder: '请选择入库日期',
        valueFormat: 'YYYY-MM-DD',
      },
      rules: 'required',
    },
    {
      fieldName: 'source',
      label: '入库来源',
      component: 'Input',
      componentProps: {
        placeholder: '请输入入库来源',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-3',
      componentProps: {
        placeholder: '请输入备注',
        rows: 3,
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '单据编码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入单据编码',
      },
    },
    {
      fieldName: 'name',
      label: '单据名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入单据名称',
      },
    },
    {
      fieldName: 'source',
      label: '入库来源',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入入库来源',
      },
    },
    {
      fieldName: 'receiptDate',
      label: '入库日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '单据状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: Object.entries(EQUIPMENT_RECEIPT_STATUS_MAP).map(
          ([value, item]) => ({
            label: item.text,
            value: Number(value),
          }),
        ),
        placeholder: '请选择单据状态',
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<MesWmEquipmentReceiptApi.EquipmentReceipt>['columns'] {
  return [
    {
      field: 'code',
      title: '单据编码',
      minWidth: 160,
      slots: { default: 'code' },
    },
    {
      field: 'name',
      title: '单据名称',
      minWidth: 150,
    },
    {
      field: 'source',
      title: '入库来源',
      minWidth: 120,
    },
    {
      field: 'receiptDate',
      title: '入库日期',
      width: 180,
      formatter: 'formatDate',
    },
    {
      field: 'status',
      title: '单据状态',
      minWidth: 100,
      slots: { default: 'status' },
    },
    {
      title: '操作',
      width: 240,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
/** è®¾å¤‡å…¥åº“单行子表的字段 */
export function useLineGridColumns(
  editable = true,
): VxeTableGridOptions<MesWmEquipmentReceiptLineApi.EquipmentReceiptLine>['columns'] {
  return [
    {
      field: 'machineryCode',
      title: '设备编码',
      minWidth: 120,
    },
    {
      field: 'machineryName',
      title: '设备名称',
      minWidth: 140,
    },
    {
      field: 'itemCode',
      title: '物料编码',
      minWidth: 120,
    },
    {
      field: 'itemName',
      title: '物料名称',
      minWidth: 140,
    },
    {
      field: 'specification',
      title: '规格型号',
      minWidth: 120,
    },
    {
      field: 'unitMeasureName',
      title: '单位',
      width: 80,
    },
    {
      field: 'quantity',
      title: '入库数量',
      width: 100,
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 120,
    },
    ...(editable
      ? [
          {
            title: '操作',
            width: 160,
            fixed: 'right',
            slots: { default: 'actions' },
          } as const,
        ]
      : []),
  ];
}
/** è®¾å¤‡å…¥åº“单行新增/修改的表单 */
export function useLineFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
  return [
    {
      fieldName: 'itemId',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'machineryId',
      label: '设备',
      component: markRaw(DvMachinerySelect),
      componentProps: {
        placeholder: '请选择设备',
        onChange: async (machinery?: { itemId?: number }) => {
          await formApi?.setFieldValue('itemId', machinery?.itemId);
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'quantity',
      label: '入库数量',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0.01,
        defaultValue: 1,
        placeholder: '请输入入库数量',
        precision: 2,
      },
      rules: 'required',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-3',
      componentProps: {
        placeholder: '请输入备注',
        rows: 3,
      },
    },
  ];
}
src/views/wls/equipmentreceipt/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,243 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmEquipmentReceiptApi } from '#/api/mes/wm/equipmentreceipt';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { MesWmEquipmentReceiptStatusEnum } from '@vben/constants';
import { Button, message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  cancelEquipmentReceipt,
  deleteEquipmentReceipt,
  getEquipmentReceiptApproveProcessList,
  getEquipmentReceiptPage,
  submitEquipmentReceiptApproval,
} from '#/api/mes/wm/equipmentreceipt';
import { $t } from '#/locales';
import ProcessSelectModal from '#/views/wls/components/process-select-modal.vue';
import { EQUIPMENT_RECEIPT_STATUS_MAP, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** åˆ›å»ºè®¾å¤‡å…¥åº“单 */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** æŸ¥çœ‹è®¾å¤‡å…¥åº“单 */
function handleDetail(row: MesWmEquipmentReceiptApi.EquipmentReceipt) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** ç¼–辑设备入库单 */
function handleEdit(row: MesWmEquipmentReceiptApi.EquipmentReceipt) {
  formModalApi.setData({ formType: 'update', id: row.id }).open();
}
/** æ‰§è¡Œå…¥åº“ */
function handleFinish(row: MesWmEquipmentReceiptApi.EquipmentReceipt) {
  formModalApi.setData({ formType: 'finish', id: row.id }).open();
}
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmitApproval(row: MesWmEquipmentReceiptApi.EquipmentReceipt) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getEquipmentReceiptApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning('暂无可用审批流程,请先配置 BPM æµç¨‹');
      return;
    }
    processModalApi
      .setData({
        id: row.id,
        processList,
        submitFn: submitEquipmentReceiptApproval,
      })
      .open();
  } catch {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¯¦æƒ… */
function handleProcessDetail(row: MesWmEquipmentReceiptApi.EquipmentReceipt) {
  if (!row.processInstanceId) {
    message.warning('该单据尚未提交审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
/** åˆ é™¤è®¾å¤‡å…¥åº“单 */
async function handleDelete(row: MesWmEquipmentReceiptApi.EquipmentReceipt) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.code]),
    duration: 0,
  });
  try {
    await deleteEquipmentReceipt(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.code]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** å–消设备入库单 */
async function handleCancel(row: MesWmEquipmentReceiptApi.EquipmentReceipt) {
  await cancelEquipmentReceipt(row.id!);
  message.success('取消成功');
  handleRefresh();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getEquipmentReceiptPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<MesWmEquipmentReceiptApi.EquipmentReceipt>,
});
const StatusEnum = MesWmEquipmentReceiptStatusEnum;
</script>
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <Grid table-title="设备入库单列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['设备入库单']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:wm:equipment-receipt:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #code="{ row }">
        <Button type="link" @click="handleDetail(row)">
          {{ row.code }}
        </Button>
      </template>
      <template #status="{ row }">
        <Tag :color="EQUIPMENT_RECEIPT_STATUS_MAP[row.status ?? 0]?.color">
          {{ EQUIPMENT_RECEIPT_STATUS_MAP[row.status ?? 0]?.text ?? row.status }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:wm:equipment-receipt:update'],
              ifShow: row.status === StatusEnum.PREPARE,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:wm:equipment-receipt:delete'],
              ifShow: row.status === StatusEnum.PREPARE,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.code]),
                confirm: handleDelete.bind(null, row),
              },
            },
            {
              label: '提交审批',
              type: 'link',
              auth: ['mes:wm:equipment-receipt:submit'],
              ifShow: row.status === StatusEnum.PREPARE,
              onClick: handleSubmitApproval.bind(null, row),
            },
            {
              label: '查看审批',
              type: 'link',
              auth: ['mes:wm:equipment-receipt:query'],
              ifShow: row.status === StatusEnum.APPROVING,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: '执行入库',
              type: 'link',
              auth: ['mes:wm:equipment-receipt:finish'],
              ifShow: row.status === StatusEnum.APPROVED,
              onClick: handleFinish.bind(null, row),
            },
            {
              label: '取消',
              type: 'link',
              danger: true,
              auth: ['mes:wm:equipment-receipt:cancel'],
              ifShow: row.status === StatusEnum.APPROVED,
              popConfirm: {
                title: '确认取消该设备入库单吗?',
                confirm: handleCancel.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/wls/equipmentreceipt/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,163 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { MesWmEquipmentReceiptApi } from '#/api/mes/wm/equipmentreceipt';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { MesWmEquipmentReceiptStatusEnum } from '@vben/constants';
import { Button, Divider, message, Popconfirm } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createEquipmentReceipt,
  finishEquipmentReceipt,
  getEquipmentReceipt,
  updateEquipmentReceipt,
} from '#/api/mes/wm/equipmentreceipt';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import LineList from './line-list.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const formData = ref<MesWmEquipmentReceiptApi.EquipmentReceipt>();
const originalSnapshot = ref(''); // è¡¨å•原始数据快照,用于提交时跳过未变更的保存请求
const isEditable = computed(() =>
  // æ˜¯å¦ä¸ºç¼–辑模式(可保存)
  ['create', 'update'].includes(formType.value),
);
const isFinish = computed(() => formType.value === 'finish'); // æ˜¯å¦ä¸ºæ‰§è¡Œå…¥åº“模式
const getTitle = computed(() => {
  if (formType.value === 'detail') {
    return $t('ui.actionTitle.view', ['设备入库单']);
  }
  if (formType.value === 'finish') {
    return '执行入库';
  }
  return formType.value === 'update'
    ? $t('ui.actionTitle.edit', ['设备入库单'])
    : $t('ui.actionTitle.create', ['设备入库单']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-1',
    labelWidth: 110,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-3',
});
/** æ‰§è¡Œå…¥åº“ */
async function handleFinish() {
  if (!formData.value?.id) {
    return;
  }
  modalApi.lock();
  try {
    await finishEquipmentReceipt(formData.value.id);
    message.success('入库成功');
    await modalApi.close();
    emit('success');
  } finally {
    modalApi.unlock();
  }
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!isEditable.value) {
      await modalApi.close();
      return;
    }
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    // æäº¤è¡¨å•
    const data =
      (await formApi.getValues()) as MesWmEquipmentReceiptApi.EquipmentReceipt;
    try {
      if (formData.value?.id) {
        await updateEquipmentReceipt({ ...formData.value, ...data });
        formData.value = { ...formData.value, ...data };
      } else {
        const id = await createEquipmentReceipt(data);
        formData.value = {
          ...data,
          id,
          status: MesWmEquipmentReceiptStatusEnum.PREPARE,
        };
        await formApi.setFieldValue('id', id);
        await formApi.setFieldValue('status', formData.value.status);
        formType.value = 'update';
      }
      originalSnapshot.value = JSON.stringify(await formApi.getValues());
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      originalSnapshot.value = '';
      return;
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setState({ schema: useFormSchema(formType.value, formApi) });
    formApi.setDisabled(
      formType.value === 'detail' || formType.value === 'finish',
    );
    modalApi.setState({ showConfirmButton: isEditable.value });
    if (data?.id) {
      modalApi.lock();
      try {
        formData.value = await getEquipmentReceipt(data.id);
        // è®¾ç½®åˆ° values
        await formApi.setValues(formData.value);
      } finally {
        modalApi.unlock();
      }
    }
    originalSnapshot.value = JSON.stringify(await formApi.getValues());
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
    <!-- éžæ–°å»ºæ¨¡å¼å±•示设备明细 -->
    <template v-if="formData?.id">
      <Divider>设备明细</Divider>
      <div class="mx-4">
        <LineList :form-type="formType" :receipt-id="formData.id" />
      </div>
    </template>
    <template #prepend-footer>
      <div class="flex flex-auto items-center gap-2">
        <Popconfirm
          v-if="isFinish"
          title="确认执行入库?执行后将设备计入设备仓库存台账。"
          @confirm="handleFinish"
        >
          <Button type="primary">执行入库</Button>
        </Popconfirm>
      </div>
    </template>
  </Modal>
</template>
src/views/wls/equipmentreceipt/modules/line-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { MesWmEquipmentReceiptLineApi } from '#/api/mes/wm/equipmentreceipt/line';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createEquipmentReceiptLine,
  getEquipmentReceiptLine,
  updateEquipmentReceiptLine,
} from '#/api/mes/wm/equipmentreceipt/line';
import { $t } from '#/locales';
import { useLineFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MesWmEquipmentReceiptLineApi.EquipmentReceiptLine>();
const receiptId = ref<number>(); // æ‰€å±žå…¥åº“单编号
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['设备'])
    : $t('ui.actionTitle.create', ['设备']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-1',
    labelWidth: 90,
  },
  layout: 'horizontal',
  schema: useLineFormSchema(),
  showDefaultActions: false,
  wrapperClass: 'grid-cols-3',
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    // æäº¤è¡¨å•
    const data =
      (await formApi.getValues()) as MesWmEquipmentReceiptLineApi.EquipmentReceiptLine;
    data.receiptId = receiptId.value;
    try {
      await (formData.value?.id
        ? updateEquipmentReceiptLine({ ...data, id: formData.value.id })
        : createEquipmentReceiptLine(data));
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    formApi.setState({ schema: useLineFormSchema(formApi) });
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      id?: number;
      receiptId: number;
    }>();
    receiptId.value = data.receiptId;
    if (!data.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getEquipmentReceiptLine(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/wls/equipmentreceipt/modules/line-list.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,142 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmEquipmentReceiptLineApi } from '#/api/mes/wm/equipmentreceipt/line';
import { computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteEquipmentReceiptLine,
  getEquipmentReceiptLineListByReceiptId,
} from '#/api/mes/wm/equipmentreceipt/line';
import { $t } from '#/locales';
import { useLineGridColumns } from '../data';
import LineForm from './line-form.vue';
const props = defineProps<{
  formType: FormType;
  receiptId: number;
}>();
const isEditable = computed(() =>
  // æ˜¯å¦å¯ç¼–辑明细行
  ['create', 'update'].includes(props.formType),
);
const [LineFormModal, lineFormModalApi] = useVbenModal({
  connectedComponent: LineForm,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ·»åŠ è®¾å¤‡ */
function handleCreate() {
  lineFormModalApi.setData({ receiptId: props.receiptId }).open();
}
/** ç¼–辑设备 */
function handleEdit(row: MesWmEquipmentReceiptLineApi.EquipmentReceiptLine) {
  lineFormModalApi.setData({ id: row.id, receiptId: props.receiptId }).open();
}
/** åˆ é™¤è®¾å¤‡ */
async function handleDelete(row: MesWmEquipmentReceiptLineApi.EquipmentReceiptLine) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.machineryName]),
    duration: 0,
  });
  try {
    await deleteEquipmentReceiptLine(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.machineryName]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useLineGridColumns(isEditable.value),
    height: 360,
    keepSource: true,
    pagerConfig: {
      enabled: false,
    },
    proxyConfig: {
      ajax: {
        query: async () => {
          if (!props.receiptId) {
            return { list: [], total: 0 };
          }
          const list = await getEquipmentReceiptLineListByReceiptId(
            props.receiptId,
          );
          return { list, total: list.length };
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
    },
  } as VxeTableGridOptions<MesWmEquipmentReceiptLineApi.EquipmentReceiptLine>,
});
</script>
<template>
  <div>
    <LineFormModal @success="handleRefresh" />
    <Grid table-title="设备明细">
      <template v-if="isEditable" #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '添加设备',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              ifShow: isEditable,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              ifShow: isEditable,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.machineryName]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </div>
</template>
src/views/wls/miscissue/index.vue
@@ -2,6 +2,8 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmMiscIssueApi } from '#/api/mes/wm/miscissue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { MesWmMiscIssueStatusEnum } from '@vben/constants';
import { downloadFileFromBlobPart } from '@vben/utils';
@@ -13,15 +15,25 @@
  cancelMiscIssue,
  deleteMiscIssue,
  exportMiscIssue,
  getMiscIssueApproveProcessList,
  getMiscIssuePage,
  submitMiscIssueApproval,
} from '#/api/mes/wm/miscissue';
import { $t } from '#/locales';
import ProcessSelectModal from '#/views/wls/components/process-select-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
@@ -48,6 +60,43 @@
/** æ‰§è¡Œå‡ºåº“ */
function handleFinish(row: MesWmMiscIssueApi.MiscIssue) {
  formModalApi.setData({ formType: 'finish', id: row.id }).open();
}
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmitApproval(row: MesWmMiscIssueApi.MiscIssue) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getMiscIssueApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning('暂无可用审批流程,请先配置 BPM æµç¨‹');
      return;
    }
    processModalApi
      .setData({
        id: row.id,
        processList,
        submitFn: submitMiscIssueApproval,
      })
      .open();
  } catch {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¯¦æƒ… */
function handleProcessDetail(row: MesWmMiscIssueApi.MiscIssue) {
  if (!row.processInstanceId) {
    message.warning('该单据尚未提交审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
/** åˆ é™¤æ‚项出库单 */
@@ -111,6 +160,7 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <Grid table-title="杂项出库单列表">
      <template #toolbar-tools>
@@ -162,6 +212,20 @@
              },
            },
            {
              label: '提交审批',
              type: 'link',
              auth: ['mes:wm-misc-issue:update'],
              ifShow: row.status === MesWmMiscIssueStatusEnum.PREPARE,
              onClick: handleSubmitApproval.bind(null, row),
            },
            {
              label: '查看审批',
              type: 'link',
              auth: ['mes:wm-misc-issue:query'],
              ifShow: row.status === MesWmMiscIssueStatusEnum.APPROVING,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: '执行出库',
              type: 'link',
              auth: ['mes:wm-misc-issue:finish'],
src/views/wls/miscissue/modules/form.vue
@@ -15,7 +15,6 @@
  createMiscIssue,
  finishMiscIssue,
  getMiscIssue,
  submitMiscIssue,
  updateMiscIssue,
} from '#/api/mes/wm/miscissue';
import { $t } from '#/locales';
@@ -32,12 +31,6 @@
  ['create', 'update'].includes(formType.value),
);
const isFinish = computed(() => formType.value === 'finish'); // æ˜¯å¦ä¸ºæ‰§è¡Œå‡ºåº“模式
const canSubmit = computed(
  () =>
    // æ˜¯å¦å¯æäº¤
    formType.value === 'update' &&
    formData.value?.status === MesWmMiscIssueStatusEnum.PREPARE,
);
const getTitle = computed(() => {
  if (formType.value === 'detail') {
    return $t('ui.actionTitle.view', ['杂项出库单']);
@@ -63,29 +56,6 @@
  showDefaultActions: false,
  wrapperClass: 'grid-cols-3',
});
/** æäº¤å‡ºåº“单:表单有修改时先保存,再调用提交接口 */
async function handleSubmit() {
  const { valid } = await formApi.validate();
  if (!valid || !formData.value?.id) {
    return;
  }
  modalApi.lock();
  try {
    const current = JSON.stringify(await formApi.getValues());
    if (current !== originalSnapshot.value) {
      const data = (await formApi.getValues()) as MesWmMiscIssueApi.MiscIssue;
      await updateMiscIssue({ ...formData.value, ...data });
      originalSnapshot.value = current;
    }
    await submitMiscIssue(formData.value.id);
    message.success('提交成功');
    await modalApi.close();
    emit('success');
  } finally {
    modalApi.unlock();
  }
}
/** æ‰§è¡Œå‡ºåº“ */
async function handleFinish() {
@@ -179,13 +149,6 @@
    </template>
    <template #prepend-footer>
      <div class="flex flex-auto items-center gap-2">
        <Popconfirm
          v-if="canSubmit"
          title="确认提交该杂项出库单?【提交后将不能修改】"
          @confirm="handleSubmit"
        >
          <Button type="primary">提交</Button>
        </Popconfirm>
        <Popconfirm
          v-if="isFinish"
          title="确认执行出库?执行后将更新库存台账。"
src/views/wls/miscreceipt/index.vue
@@ -2,6 +2,8 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmMiscReceiptApi } from '#/api/mes/wm/miscreceipt';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { MesWmMiscReceiptStatusEnum } from '@vben/constants';
import { downloadFileFromBlobPart } from '@vben/utils';
@@ -13,15 +15,25 @@
  cancelMiscReceipt,
  deleteMiscReceipt,
  exportMiscReceipt,
  getMiscReceiptApproveProcessList,
  getMiscReceiptPage,
  submitMiscReceiptApproval,
} from '#/api/mes/wm/miscreceipt';
import { $t } from '#/locales';
import ProcessSelectModal from '#/views/wls/components/process-select-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
@@ -48,6 +60,43 @@
/** æ‰§è¡Œå…¥åº“ */
function handleFinish(row: MesWmMiscReceiptApi.MiscReceipt) {
  formModalApi.setData({ formType: 'finish', id: row.id }).open();
}
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmitApproval(row: MesWmMiscReceiptApi.MiscReceipt) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getMiscReceiptApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning('暂无可用审批流程,请先配置 BPM æµç¨‹');
      return;
    }
    processModalApi
      .setData({
        id: row.id,
        processList,
        submitFn: submitMiscReceiptApproval,
      })
      .open();
  } catch {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¯¦æƒ… */
function handleProcessDetail(row: MesWmMiscReceiptApi.MiscReceipt) {
  if (!row.processInstanceId) {
    message.warning('该单据尚未提交审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
/** åˆ é™¤æ‚项入库单 */
@@ -111,6 +160,7 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <Grid table-title="杂项入库单列表">
      <template #toolbar-tools>
@@ -162,6 +212,20 @@
              },
            },
            {
              label: '提交审批',
              type: 'link',
              auth: ['mes:wm:misc-receipt:submit'],
              ifShow: row.status === MesWmMiscReceiptStatusEnum.PREPARE,
              onClick: handleSubmitApproval.bind(null, row),
            },
            {
              label: '查看审批',
              type: 'link',
              auth: ['mes:wm:misc-receipt:query'],
              ifShow: row.status === MesWmMiscReceiptStatusEnum.APPROVING,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: '执行入库',
              type: 'link',
              auth: ['mes:wm-misc-receipt:finish'],
src/views/wls/miscreceipt/modules/form.vue
@@ -15,7 +15,6 @@
  createMiscReceipt,
  finishMiscReceipt,
  getMiscReceipt,
  submitMiscReceipt,
  updateMiscReceipt,
} from '#/api/mes/wm/miscreceipt';
import { $t } from '#/locales';
@@ -32,12 +31,6 @@
  ['create', 'update'].includes(formType.value),
);
const isFinish = computed(() => formType.value === 'finish'); // æ˜¯å¦ä¸ºæ‰§è¡Œå…¥åº“模式
const canSubmit = computed(
  () =>
    // æ˜¯å¦å¯æäº¤
    formType.value === 'update' &&
    formData.value?.status === MesWmMiscReceiptStatusEnum.PREPARE,
);
const getTitle = computed(() => {
  if (formType.value === 'detail') {
    return $t('ui.actionTitle.view', ['杂项入库单']);
@@ -63,30 +56,6 @@
  showDefaultActions: false,
  wrapperClass: 'grid-cols-3',
});
/** æäº¤å…¥åº“单:表单有修改时先保存,再调用提交接口 */
async function handleSubmit() {
  const { valid } = await formApi.validate();
  if (!valid || !formData.value?.id) {
    return;
  }
  modalApi.lock();
  try {
    const current = JSON.stringify(await formApi.getValues());
    if (current !== originalSnapshot.value) {
      const data =
        (await formApi.getValues()) as MesWmMiscReceiptApi.MiscReceipt;
      await updateMiscReceipt({ ...formData.value, ...data });
      originalSnapshot.value = current;
    }
    await submitMiscReceipt(formData.value.id);
    message.success('提交成功');
    await modalApi.close();
    emit('success');
  } finally {
    modalApi.unlock();
  }
}
/** æ‰§è¡Œå…¥åº“ */
async function handleFinish() {
@@ -180,13 +149,6 @@
    </template>
    <template #prepend-footer>
      <div class="flex flex-auto items-center gap-2">
        <Popconfirm
          v-if="canSubmit"
          title="确认提交该杂项入库单?【提交后将不能修改】"
          @confirm="handleSubmit"
        >
          <Button type="primary">提交</Button>
        </Popconfirm>
        <Popconfirm
          v-if="isFinish"
          title="确认执行入库?执行后将更新库存台账。"
src/views/wls/returnvendor/index.vue
@@ -2,6 +2,8 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmReturnVendorApi } from '#/api/mes/wm/returnvendor';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { MesWmReturnVendorStatusEnum } from '@vben/constants';
import { downloadFileFromBlobPart } from '@vben/utils';
@@ -13,15 +15,25 @@
  cancelReturnVendor,
  deleteReturnVendor,
  exportReturnVendor,
  getReturnVendorApproveProcessList,
  getReturnVendorPage,
  submitReturnVendorApproval,
} from '#/api/mes/wm/returnvendor';
import { $t } from '#/locales';
import ProcessSelectModal from '#/views/wls/components/process-select-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
@@ -48,6 +60,43 @@
/** æ‰§è¡Œæ‹£è´§ */
function handleStock(row: MesWmReturnVendorApi.ReturnVendor) {
  formModalApi.setData({ formType: 'stock', id: row.id }).open();
}
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmitApproval(row: MesWmReturnVendorApi.ReturnVendor) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getReturnVendorApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning('暂无可用审批流程,请先配置 BPM æµç¨‹');
      return;
    }
    processModalApi
      .setData({
        id: row.id,
        processList,
        submitFn: submitReturnVendorApproval,
      })
      .open();
  } catch {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¯¦æƒ… */
function handleProcessDetail(row: MesWmReturnVendorApi.ReturnVendor) {
  if (!row.processInstanceId) {
    message.warning('该单据尚未提交审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
/** å®Œæˆé€€è´§ */
@@ -116,6 +165,7 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <Grid table-title="供应商退货单列表">
      <template #toolbar-tools>
@@ -147,6 +197,13 @@
        <TableAction
          :actions="[
            {
              label: '提交审批',
              type: 'link',
              auth: ['mes:wm-return-vendor:update'],
              ifShow: row.status === MesWmReturnVendorStatusEnum.PREPARE,
              onClick: handleSubmitApproval.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
@@ -167,6 +224,13 @@
              },
            },
            {
              label: '查看审批',
              type: 'link',
              auth: ['mes:wm-return-vendor:query'],
              ifShow: row.status === MesWmReturnVendorStatusEnum.APPROVING,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: '执行拣货',
              type: 'link',
              auth: ['mes:wm-return-vendor:update'],
src/views/wls/returnvendor/modules/form.vue
@@ -18,7 +18,6 @@
  finishReturnVendor,
  getReturnVendor,
  stockReturnVendor,
  submitReturnVendor,
  updateReturnVendor,
} from '#/api/mes/wm/returnvendor';
import { $t } from '#/locales';
@@ -36,12 +35,6 @@
);
const isStock = computed(() => formType.value === 'stock'); // æ˜¯å¦ä¸ºæ‹£è´§æ¨¡å¼
const isFinish = computed(() => formType.value === 'finish'); // æ˜¯å¦ä¸ºå®Œæˆé€€è´§æ¨¡å¼
const canSubmit = computed(
  () =>
    // æ˜¯å¦å¯æäº¤
    formType.value === 'update' &&
    formData.value?.status === MesWmReturnVendorStatusEnum.PREPARE,
);
const getTitle = computed(() => {
  if (formType.value === 'detail') {
    return $t('ui.actionTitle.view', ['供应商退货单']);
@@ -80,30 +73,6 @@
    return formatDate(value, 'YYYY-MM-DD HH:mm:ss');
  }
  return undefined;
}
/** æäº¤é€€è´§å•:表单有修改时先保存,再调用提交接口 */
async function handleSubmit() {
  const { valid } = await formApi.validate();
  if (!valid || !formData.value?.id) {
    return;
  }
  modalApi.lock();
  try {
    const current = JSON.stringify(await formApi.getValues());
    if (current !== originalSnapshot.value) {
      const data =
        (await formApi.getValues()) as MesWmReturnVendorApi.ReturnVendor;
      await updateReturnVendor({ ...formData.value, ...data });
      originalSnapshot.value = current;
    }
    await submitReturnVendor(formData.value.id);
    message.success('提交成功');
    await modalApi.close();
    emit('success');
  } finally {
    modalApi.unlock();
  }
}
/** æ‰§è¡Œæ‹£è´§ï¼šæ‹£è´§æ•°é‡ä¸Žé€€è´§æ•°é‡ä¸ä¸€è‡´æ—¶äºŒæ¬¡ç¡®è®¤ */
@@ -228,13 +197,6 @@
    </template>
    <template #prepend-footer>
      <div class="flex flex-auto items-center gap-2">
        <Popconfirm
          v-if="canSubmit"
          title="确认提交该退货单?【提交后将不能修改】"
          @confirm="handleSubmit"
        >
          <Button type="primary">提交</Button>
        </Popconfirm>
        <Button v-if="isStock" type="primary" @click="handleStock">
          æ‰§è¡Œæ‹£è´§
        </Button>
src/views/wls/sn/data.ts
@@ -1,11 +1,26 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmSnApi } from '#/api/mes/wm/sn';
import type { DescriptionItemSchema } from '#/components/description';
import { markRaw } from 'vue';
import { MesWmSnStatusEnum } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { getRangePickerDefaultProps } from '#/utils';
import { MdItemSelect } from '#/views/mes/md/item/components';
import {
  WmWarehouseAreaSelect,
  WmWarehouseLocationSelect,
  WmWarehouseSelect,
} from '#/views/wls/warehouse/components';
/** SN çŠ¶æ€é€‰é¡¹ï¼ˆæ— ç‹¬ç«‹å­—å…¸ï¼Œä½¿ç”¨å¸¸é‡æ˜ å°„ï¼‰ */
export const SN_STATUS_OPTIONS = [
  { label: '在库', value: MesWmSnStatusEnum.IN_STOCK },
  { label: '已出库', value: MesWmSnStatusEnum.OUT_STOCK },
];
/** ç”Ÿæˆ SN ç çš„表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -181,3 +196,275 @@
    },
  ];
}
/** åœ¨åº“单件列表的搜索表单 */
export function useInStockGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: 'SN ç ',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入 SN ç ',
      },
    },
    {
      fieldName: 'itemId',
      label: '物料',
      component: markRaw(MdItemSelect),
      componentProps: {
        placeholder: '请选择物料',
      },
    },
    {
      fieldName: 'batchCode',
      label: '批次号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入批次号',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: SN_STATUS_OPTIONS,
        placeholder: '请选择状态',
      },
    },
  ];
}
/** åœ¨åº“单件列表的字段 */
export function useInStockGridColumns(): VxeTableGridOptions<MesWmSnApi.Sn>['columns'] {
  return [
    {
      field: 'code',
      title: 'SN ç ',
      minWidth: 180,
    },
    {
      field: 'itemCode',
      title: '物料编码',
      minWidth: 120,
    },
    {
      field: 'itemName',
      title: '物料名称',
      minWidth: 150,
    },
    {
      field: 'specification',
      title: '规格型号',
      minWidth: 120,
    },
    {
      field: 'unitName',
      title: '单位',
      width: 80,
    },
    {
      field: 'batchCode',
      title: '批次号',
      minWidth: 120,
    },
    {
      field: 'status',
      title: '状态',
      width: 90,
      slots: { default: 'status' },
    },
    {
      field: 'warehouseId',
      title: '仓库',
      minWidth: 140,
      slots: { default: 'locations' },
    },
    {
      field: 'createTime',
      title: '生成时间',
      width: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 140,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
/** SN ç å…¥åº“登记的表单 */
export function useInStockFormSchema(
  formApi?: VbenFormApi,
): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: 'SN ç ',
      component: 'Input',
      componentProps: {
        placeholder: '扫码枪扫入或输入 SN ç ',
      },
      rules: 'required',
    },
    {
      fieldName: 'itemId',
      label: '物料',
      component: markRaw(MdItemSelect),
      componentProps: {
        placeholder: '请选择物料(须启用序列号管理)',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'warehouseId',
      label: '仓库',
      component: markRaw(WmWarehouseSelect),
      componentProps: {
        onChange: () =>
          formApi?.setValues({ areaId: undefined, locationId: undefined }),
        placeholder: '请选择仓库',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'locationId',
      label: '库区',
      component: markRaw(WmWarehouseLocationSelect),
      dependencies: {
        triggerFields: ['warehouseId'],
        show: (values) => !!values.warehouseId,
        componentProps: (values) => ({
          onChange: () => formApi?.setFieldValue('areaId', undefined),
          placeholder: '请选择库区',
          warehouseId: values.warehouseId,
        }),
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'areaId',
      label: '库位',
      component: markRaw(WmWarehouseAreaSelect),
      dependencies: {
        triggerFields: ['locationId'],
        show: (values) => !!values.locationId,
        componentProps: (values) => ({
          locationId: values.locationId,
          placeholder: '请选择库位',
        }),
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'batchCode',
      label: '批次号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入批次号',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        placeholder: '请输入备注',
        rows: 2,
      },
    },
  ];
}
/** SN ç å‡ºåº“核销的表单 */
export function useOutStockFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: 'SN ç ',
      component: 'Input',
      componentProps: {
        placeholder: '扫码枪扫入或输入 SN ç ',
      },
      rules: 'required',
    },
    {
      fieldName: 'itemId',
      label: '物料',
      component: markRaw(MdItemSelect),
      componentProps: {
        placeholder: '请选择物料',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        placeholder: '请输入备注',
        rows: 2,
      },
    },
  ];
}
/** SN ç å•件详情的描述字段 */
export function useSnDetailSchema(): DescriptionItemSchema[] {
  return [
    {
      field: 'code',
      label: 'SN ç ',
      render: (value) => value || '-',
    },
    {
      field: 'itemCode',
      label: '物料编码',
      render: (value) => value || '-',
    },
    {
      field: 'itemName',
      label: '物料名称',
      render: (value) => value || '-',
    },
    {
      field: 'batchCode',
      label: '批次号',
      render: (value) => value || '-',
    },
    {
      field: 'status',
      label: '状态',
      render: (value) =>
        value === MesWmSnStatusEnum.OUT_STOCK
          ? '已出库'
          : value === MesWmSnStatusEnum.IN_STOCK
            ? '在库'
            : '-',
    },
    {
      field: 'warehouseId',
      label: '在库位置',
      slot: 'location',
    },
    {
      field: 'lastTransactionTypeName',
      label: '最近流水',
      slot: 'lastTransaction',
    },
    {
      field: 'lastTransactionTime',
      label: '最近流水时间',
      render: (value) => formatDateTime(value) || '-',
    },
  ];
}
src/views/wls/sn/index.vue
@@ -2,10 +2,12 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmSnApi } from '#/api/mes/wm/sn';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import { message, Tabs, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -13,12 +15,28 @@
  exportSnDetailExcel,
  exportSnGroupExcel,
  getSnGroupPage,
  getSnInStockPage,
} from '#/api/mes/wm/sn';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import {
  useGridColumns,
  useGridFormSchema,
  useInStockGridColumns,
  useInStockGridFormSchema,
} from './data';
import {
  formatWmLocation,
  getWmLocationMaps,
  type WmLocationMaps,
} from './location-maps';
import Detail from './modules/detail.vue';
import Form from './modules/form.vue';
import InStockModal from './modules/in-stock-modal.vue';
import OutStockModal from './modules/out-stock-modal.vue';
import SnDetailModal from './modules/sn-detail-modal.vue';
const activeKey = ref('group');
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -30,9 +48,25 @@
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
const [InStockModalRef, inStockModalApi] = useVbenModal({
  connectedComponent: InStockModal,
  destroyOnClose: true,
});
const [OutStockModalRef, outStockModalApi] = useVbenModal({
  connectedComponent: OutStockModal,
  destroyOnClose: true,
});
const snDetailModalRef = ref<InstanceType<typeof SnDetailModal>>();
/** åˆ·æ–°å½“前页签 */
function handleRefresh() {
  gridApi.query();
  if (activeKey.value === 'group') {
    gridApi.query();
  } else {
    inStockGridApi.query();
  }
}
/** ç”Ÿæˆ SN ç  */
@@ -74,6 +108,34 @@
  }
}
/** å…¥åº“登记 */
function handleInStock() {
  inStockModalApi.open();
}
/** å‡ºåº“核销 */
function handleOutStock() {
  outStockModalApi.open();
}
/** æŸ¥çœ‹å•件详情 */
function handleSnDetail(row: MesWmSnApi.Sn) {
  snDetailModalRef.value?.open(row.code);
}
/** åœ¨åº“单件列表位置名称映射(懒加载,首次访问时初始化) */
const locationMapsRef = ref<Awaited<ReturnType<typeof getWmLocationMaps>>>();
const EMPTY_MAPS: WmLocationMaps = {
  warehouseNameMap: {},
  locationNameMap: {},
  areaNameMap: {},
};
async function ensureLocationMaps() {
  if (!locationMapsRef.value) {
    locationMapsRef.value = await getWmLocationMaps();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -103,75 +165,171 @@
    },
  } as VxeTableGridOptions<MesWmSnApi.SnGroup>,
});
const [InStockGrid, inStockGridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useInStockGridFormSchema(),
  },
  gridOptions: {
    columns: useInStockGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getSnInStockPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<MesWmSnApi.Sn>,
});
// é¦–次挂载时预热位置名称映射,供列表展示
void ensureLocationMaps();
</script>
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <DetailModal />
    <InStockModalRef @success="handleRefresh" />
    <OutStockModalRef @success="handleRefresh" />
    <SnDetailModal ref="snDetailModalRef" />
    <Grid table-title="SN ç åˆ—表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '生成 SN ç ',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:wm-sn:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:wm-sn:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #count="{ row }">
        <TableAction
          :actions="[
            {
              label: `${row.count ?? 0}`,
              type: 'link',
              auth: ['mes:wm-sn:query'],
              onClick: handleDetail.bind(null, row),
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '查看明细',
              type: 'link',
              auth: ['mes:wm-sn:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: '导出明细',
              type: 'link',
              auth: ['mes:wm-sn:export'],
              onClick: handleExportDetail.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              auth: ['mes:wm-sn:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [
                  row.batchCode || row.itemName,
                ]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
    <Tabs v-model:active-key="activeKey" class="px-4 pt-1" type="card">
      <Tabs.TabPane key="group" tab="SN ç åˆ†ç»„">
        <Grid table-title="SN ç åˆ—表">
          <template #toolbar-tools>
            <TableAction
              :actions="[
                {
                  label: '生成 SN ç ',
                  type: 'primary',
                  icon: ACTION_ICON.ADD,
                  auth: ['mes:wm-sn:create'],
                  onClick: handleCreate,
                },
                {
                  label: $t('ui.actionTitle.export'),
                  type: 'primary',
                  icon: ACTION_ICON.DOWNLOAD,
                  auth: ['mes:wm-sn:export'],
                  onClick: handleExport,
                },
              ]"
            />
          </template>
          <template #count="{ row }">
            <TableAction
              :actions="[
                {
                  label: `${row.count ?? 0}`,
                  type: 'link',
                  auth: ['mes:wm-sn:query'],
                  onClick: handleDetail.bind(null, row),
                },
              ]"
            />
          </template>
          <template #actions="{ row }">
            <TableAction
              :actions="[
                {
                  label: '查看明细',
                  type: 'link',
                  auth: ['mes:wm-sn:query'],
                  onClick: handleDetail.bind(null, row),
                },
                {
                  label: '导出明细',
                  type: 'link',
                  auth: ['mes:wm-sn:export'],
                  onClick: handleExportDetail.bind(null, row),
                },
                {
                  label: $t('common.delete'),
                  type: 'link',
                  danger: true,
                  auth: ['mes:wm-sn:delete'],
                  popConfirm: {
                    title: $t('ui.actionMessage.deleteConfirm', [
                      row.batchCode || row.itemName,
                    ]),
                    confirm: handleDelete.bind(null, row),
                  },
                },
              ]"
            />
          </template>
        </Grid>
      </Tabs.TabPane>
      <Tabs.TabPane key="in-stock" tab="在库单件">
        <InStockGrid table-title="SN å•件状态列表">
          <template #toolbar-tools>
            <TableAction
              :actions="[
                {
                  label: '入库登记',
                  type: 'primary',
                  icon: ACTION_ICON.ADD,
                  auth: ['mes:wm-sn:update'],
                  onClick: handleInStock,
                },
                {
                  label: '出库核销',
                  type: 'primary',
                  danger: true,
                  icon: ACTION_ICON.DELETE,
                  auth: ['mes:wm-sn:update'],
                  onClick: handleOutStock,
                },
              ]"
            />
          </template>
          <template #status="{ row }">
            <Tag :color="row.status === 1 ? 'green' : 'orange'">
              {{ row.status === 1 ? '在库' : '已出库' }}
            </Tag>
          </template>
          <template #locations="{ row }">
            <span v-if="row.status === 1">
              {{
                formatWmLocation(
                  locationMapsRef ?? EMPTY_MAPS,
                  row.warehouseId,
                  row.locationId,
                  row.areaId,
                )
              }}
            </span>
            <span v-else>-</span>
          </template>
          <template #actions="{ row }">
            <TableAction
              :actions="[
                {
                  label: '详情',
                  type: 'link',
                  auth: ['mes:wm-sn:query'],
                  onClick: handleSnDetail.bind(null, row),
                },
              ]"
            />
          </template>
        </InStockGrid>
      </Tabs.TabPane>
    </Tabs>
  </Page>
</template>
</template>
src/views/wls/sn/location-maps.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
import { getWarehouseAreaSimpleList } from '#/api/mes/wm/warehouse/area';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import { getWarehouseLocationSimpleList } from '#/api/mes/wm/warehouse/location';
export interface WmLocationMaps {
  warehouseNameMap: Record<number, string>;
  locationNameMap: Record<number, string>;
  areaNameMap: Record<number, string>;
}
let cached: WmLocationMaps | null = null;
/** åŠ è½½ä»“åº“/库区/库位 ID -> åç§°æ˜ å°„(模块级缓存,仅首次加载) */
export async function getWmLocationMaps(): Promise<WmLocationMaps> {
  if (cached) {
    return cached;
  }
  const [warehouses, locations, areas] = await Promise.all([
    getWarehouseSimpleList(),
    getWarehouseLocationSimpleList(),
    getWarehouseAreaSimpleList(),
  ]);
  cached = {
    warehouseNameMap: Object.fromEntries(
      warehouses.map((w) => [w.id!, w.name || w.code || '']),
    ),
    locationNameMap: Object.fromEntries(
      locations.map((l) => [l.id!, l.name || l.code || '']),
    ),
    areaNameMap: Object.fromEntries(
      areas.map((a) => [a.id!, a.name || a.code || '']),
    ),
  };
  return cached;
}
/** æ ¹æ® ID æ¸²æŸ“ ä»“库/库区/库位 æ–‡æœ¬ï¼ˆID ç¼ºå¤±æ—¶è¿”回空串) */
export function formatWmLocation(
  maps: WmLocationMaps,
  warehouseId?: number,
  locationId?: number,
  areaId?: number,
): string {
  return [
    warehouseId ? maps.warehouseNameMap[warehouseId] : '',
    locationId ? maps.locationNameMap[locationId] : '',
    areaId ? maps.areaNameMap[areaId] : '',
  ]
    .filter(Boolean)
    .join(' / ');
}
src/views/wls/sn/modules/in-stock-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
<script lang="ts" setup>
import type { MesWmSnApi } from '#/api/mes/wm/sn';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { registerSnInStock } from '#/api/mes/wm/sn';
import { useInStockFormSchema } from '../data';
defineOptions({ name: 'WlsSnInStockModal' });
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-1',
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as MesWmSnApi.SnInStock;
    try {
      await registerSnInStock(data);
      await modalApi.close();
      emit('success');
      message.success('入库登记成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      return;
    }
    formApi.setState({ schema: useInStockFormSchema(formApi) });
    await formApi.resetForm();
  },
});
</script>
<template>
  <Modal title="SN ç å…¥åº“登记" class="w-1/2">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/wls/sn/modules/out-stock-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
<script lang="ts" setup>
import type { MesWmSnApi } from '#/api/mes/wm/sn';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { outboundSn } from '#/api/mes/wm/sn';
import { useOutStockFormSchema } from '../data';
defineOptions({ name: 'WlsSnOutStockModal' });
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-1',
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as MesWmSnApi.SnOutStock;
    try {
      await outboundSn(data);
      await modalApi.close();
      emit('success');
      message.success('出库核销成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      return;
    }
    formApi.setState({ schema: useOutStockFormSchema() });
    await formApi.resetForm();
  },
});
</script>
<template>
  <Modal title="SN ç å‡ºåº“核销" class="w-1/2">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/wls/sn/modules/sn-detail-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,118 @@
<script lang="ts" setup>
import type { MesWmSnApi } from '#/api/mes/wm/sn';
import { computed, ref } from 'vue';
import { Button, Empty, Input, message, Modal } from 'ant-design-vue';
import { getSnDetail } from '#/api/mes/wm/sn';
import { useDescription } from '#/components/description';
import { useSnDetailSchema } from '../data';
import { formatWmLocation, getWmLocationMaps } from '../location-maps';
defineOptions({ name: 'WlsSnDetailModal' });
const open = ref(false);
const code = ref('');
const detail = ref<MesWmSnApi.SnDetail>();
const loading = ref(false);
const locationMaps = ref<Awaited<ReturnType<typeof getWmLocationMaps>>>();
const locationText = computed(() =>
  detail.value
    ? formatWmLocation(
        locationMaps.value ?? {
          warehouseNameMap: {},
          locationNameMap: {},
          areaNameMap: {},
        },
        detail.value.warehouseId,
        detail.value.locationId,
        detail.value.areaId,
      )
    : '-',
);
const lastTransactionText = computed(() => {
  const d = detail.value;
  if (!d) {
    return '-';
  }
  const direction = d.lastTransactionTypeName || '';
  const quantity = d.lastTransactionQuantity ?? '';
  const bizCode = d.lastTransactionBizCode || '-';
  return [direction, quantity, bizCode].filter(Boolean).join(' ');
});
async function loadDetail(snCode: string) {
  loading.value = true;
  try {
    detail.value = await getSnDetail(snCode);
    if (!locationMaps.value) {
      locationMaps.value = await getWmLocationMaps();
    }
  } finally {
    loading.value = false;
  }
}
async function handleQuery() {
  if (!code.value?.trim()) {
    message.warning('请输入或扫码获取 SN ç ');
    return;
  }
  await loadDetail(code.value.trim());
}
function openModal(snCode?: string) {
  open.value = true;
  code.value = snCode ?? '';
  detail.value = undefined;
  if (snCode) {
    void loadDetail(snCode);
  }
}
defineExpose({ open: openModal });
const [Descriptions] = useDescription({
  bordered: true,
  column: 1,
  schema: useSnDetailSchema(),
  useCard: false,
  labelStyle: { width: '80px', whiteSpace: 'nowrap' },
});
</script>
<template>
  <Modal v-model:open="open" title="SN ç å•件详情" width="720px">
    <div class="flex gap-2">
      <Input
        v-model:value="code"
        placeholder="输入或扫码 SN ç åŽæŸ¥è¯¢"
        @press-enter="handleQuery"
      />
      <Button type="primary" :loading="loading" @click="handleQuery">
        æŸ¥è¯¢
      </Button>
    </div>
    <div class="mt-4">
      <Descriptions v-if="detail" :data="detail">
        <template #location>
          {{ locationText }}
        </template>
        <template #lastTransaction>
          {{ lastTransactionText }}
        </template>
      </Descriptions>
      <Empty
        v-else
        :description="loading ? '查询中...' : '暂无数据,请输入 SN ç æŸ¥è¯¢'"
      />
    </div>
    <template #footer>
      <Button @click="open = false">关闭</Button>
    </template>
  </Modal>
</template>
src/views/wls/stocktaking/plan/data.ts
@@ -133,6 +133,40 @@
      rules: z.boolean().default(false),
    },
    {
      fieldName: 'autoGenerate',
      label: '自动生成',
      component: 'Switch',
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(false),
    },
    {
      fieldName: 'generateCron',
      label: '生成周期(Cron)',
      component: 'Input',
      componentProps: {
        placeholder: '请输入 Cron è¡¨è¾¾å¼ï¼Œå¦‚ 0 0 2 * * ?',
        helpMessage:
          '启用自动生成后,需在「系统管理->定时任务」配置 handler=MesWmStockTakingPlanAutoJob çš„ cron ä»»åŠ¡ï¼Œåˆ°æœŸè‡ªåŠ¨æŒ‰æœ¬æ–¹æ¡ˆç”Ÿæˆç›˜ç‚¹ä»»åŠ¡',
      },
      dependencies: {
        triggerFields: ['autoGenerate'],
        show: (values) => !!values.autoGenerate,
      },
    },
    {
      fieldName: 'lastGenerateTime',
      label: '上次生成时间',
      component: 'Input',
      disabled: true,
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
@@ -238,6 +272,21 @@
      },
    },
    {
      field: 'autoGenerate',
      title: '自动生成',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
      },
    },
    {
      field: 'lastGenerateTime',
      title: '上次生成时间',
      width: 180,
      formatter: 'formatDateTime',
    },
    {
      field: 'status',
      title: '状态',
      width: 120,
src/views/wls/stocktaking/task/data.ts
@@ -29,13 +29,11 @@
} from '#/views/wls/warehouse/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'execute' | 'submit' | 'update';
export type FormType = 'create' | 'detail' | 'submit' | 'update';
/** è¡¨å•头部是否只读(提交、执行盘点、详情态) */
/** è¡¨å•头部是否只读(提交、详情态) */
function isHeaderReadonly(formType: FormType): boolean {
  return (
    formType === 'detail' || formType === 'execute' || formType === 'submit'
  );
  return formType === 'detail' || formType === 'submit';
}
/** æ–°å¢ž/修改的表单 */
src/views/wls/stocktaking/task/index.vue
@@ -3,6 +3,7 @@
import type { MesWmStockTakingTaskApi } from '#/api/mes/wm/stocktaking/task';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { MesWmStockTakingTaskStatusEnum } from '@vben/constants';
@@ -15,7 +16,9 @@
  cancelStockTaking,
  deleteStockTaking,
  exportStockTaking,
  getStockTakingApproveProcessList,
  getStockTakingPage,
  submitStockTakingApproval,
} from '#/api/mes/wm/stocktaking/task';
import {
  importStockTakingQuantity,
@@ -23,11 +26,19 @@
} from '#/api/wls/stocktaking/task/line';
import { $t } from '#/locales';
import ProcessSelectModal from '#/views/wls/components/process-select-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
@@ -51,14 +62,46 @@
  formModalApi.setData({ formType: 'update', id: row.id }).open();
}
/** æäº¤ç›˜ç‚¹ä»»åŠ¡ */
/** å¼€å§‹ç›˜ç‚¹ */
function handleSubmit(row: MesWmStockTakingTaskApi.StockTakingTask) {
  formModalApi.setData({ formType: 'submit', id: row.id }).open();
}
/** æ‰§è¡Œç›˜ç‚¹ */
function handleExecute(row: MesWmStockTakingTaskApi.StockTakingTask) {
  formModalApi.setData({ formType: 'execute', id: row.id }).open();
/** æäº¤ç»“果审批 - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmitApproval(row: MesWmStockTakingTaskApi.StockTakingTask) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getStockTakingApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning('暂无可用审批流程,请先配置 BPM æµç¨‹');
      return;
    }
    processModalApi
      .setData({
        id: row.id,
        processList,
        submitFn: submitStockTakingApproval,
      })
      .open();
  } catch {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¯¦æƒ… */
function handleProcessDetail(row: MesWmStockTakingTaskApi.StockTakingTask) {
  if (!row.processInstanceId) {
    message.warning('该任务尚未提交结果审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
/** å–消盘点任务 */
@@ -159,6 +202,7 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <Grid table-title="盘点任务列表">
      <template #toolbar-tools>
@@ -205,7 +249,7 @@
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交',
              label: '开始盘点',
              type: 'link',
              auth: ['mes:wm-stock-taking-task:update'],
              ifShow: row.status === StatusEnum.PREPARE,
@@ -224,13 +268,6 @@
              },
            },
            {
              label: '执行盘点',
              type: 'link',
              auth: ['mes:wm-stock-taking-task:update'],
              ifShow: row.status === StatusEnum.APPROVING,
              onClick: handleExecute.bind(null, row),
            },
            {
              label: '导入实盘数',
              type: 'link',
              icon: ACTION_ICON.UPLOAD,
@@ -239,6 +276,20 @@
              onClick: handleImport.bind(null, row),
            },
            {
              label: '提交结果审批',
              type: 'link',
              auth: ['mes:wm-stock-taking-task:update'],
              ifShow: row.status === StatusEnum.APPROVING,
              onClick: handleSubmitApproval.bind(null, row),
            },
            {
              label: '查看审批',
              type: 'link',
              auth: ['mes:wm-stock-taking-task:query'],
              ifShow: row.status === StatusEnum.APPROVING,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: '取消',
              type: 'link',
              danger: true,
src/views/wls/stocktaking/task/modules/form.vue
@@ -14,7 +14,6 @@
import { useVbenForm } from '#/adapter/form';
import {
  createStockTaking,
  finishStockTaking,
  getStockTaking,
  submitStockTaking,
  updateStockTaking,
@@ -35,29 +34,23 @@
const isEditable = computed(() =>
  ['create', 'update'].includes(formType.value),
);
const isExecute = computed(() => formType.value === 'execute'); // æ˜¯å¦æ‰§è¡Œç›˜ç‚¹æ¨¡å¼
const isSubmit = computed(() => formType.value === 'submit'); // æ˜¯å¦æäº¤æ¨¡å¼
const canSubmit = computed(
  () =>
    formType.value === 'update' &&
    formData.value?.status === MesWmStockTakingTaskStatusEnum.PREPARE,
const isSubmit = computed(() => formType.value === 'submit'); // æ˜¯å¦å¼€å§‹ç›˜ç‚¹æ¨¡å¼
const isResultEditable = computed(
  // ç›˜ç‚¹ä¸­ï¼ˆå®¡æ‰¹ä¸­ï¼‰å¯ç»´æŠ¤ç›˜ç‚¹ç»“æžœ
  () => formData.value?.status === MesWmStockTakingTaskStatusEnum.APPROVING,
);
const showLineTab = computed(() => !formData.value?.blindFlag); // ç›²ç›˜ä¸å±•示盘点清单
const showResultTab = computed(
  () =>
    isExecute.value ||
    (!!formData.value?.status &&
      formData.value.status !== MesWmStockTakingTaskStatusEnum.PREPARE),
    !!formData.value?.status &&
    formData.value.status !== MesWmStockTakingTaskStatusEnum.PREPARE,
);
const getTitle = computed(() => {
  if (formType.value === 'detail') {
    return $t('ui.actionTitle.view', ['盘点任务']);
  }
  if (formType.value === 'execute') {
    return '执行盘点';
  }
  if (formType.value === 'submit') {
    return '提交盘点任务';
    return '开始盘点';
  }
  return formType.value === 'update'
    ? $t('ui.actionTitle.edit', ['盘点任务'])
@@ -78,7 +71,7 @@
  wrapperClass: 'grid-cols-3',
});
/** æäº¤ç›˜ç‚¹ä»»åŠ¡ï¼šè¡¨å•æœ‰ä¿®æ”¹æ—¶å…ˆä¿å­˜ï¼Œå†è°ƒç”¨æäº¤æŽ¥å£ */
/** å¼€å§‹ç›˜ç‚¹ï¼šè¡¨å•有修改时先保存,再提交 */
async function handleSubmit() {
  const { valid } = await formApi.validate();
  if (!valid || !formData.value?.id) {
@@ -94,23 +87,7 @@
      originalSnapshot.value = current;
    }
    await submitStockTaking(formData.value.id);
    message.success('提交成功');
    await modalApi.close();
    emit('success');
  } finally {
    modalApi.unlock();
  }
}
/** æ‰§è¡Œç›˜ç‚¹ */
async function handleExecute() {
  if (!formData.value?.id) {
    return;
  }
  modalApi.lock();
  try {
    await finishStockTaking(formData.value.id);
    message.success('执行盘点成功');
    message.success('开始盘点成功');
    await modalApi.close();
    emit('success');
  } finally {
@@ -163,7 +140,7 @@
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    subTabsName.value = data.formType === 'execute' ? 'results' : 'lines';
    subTabsName.value = 'lines';
    formApi.setState({ schema: useFormSchema(formType.value, formApi) });
    formApi.setDisabled(!isEditable.value);
    modalApi.setState({ showConfirmButton: isEditable.value });
@@ -199,7 +176,7 @@
      </Tabs.TabPane>
      <Tabs.TabPane v-if="showResultTab" key="results" tab="盘点结果">
        <ResultList
          :form-type="isExecute ? 'execute' : 'detail'"
          :form-type="isResultEditable ? 'execute' : 'detail'"
          :task-id="formData.id"
        />
      </Tabs.TabPane>
@@ -207,18 +184,11 @@
    <template #prepend-footer>
      <div class="flex flex-auto items-center gap-2">
        <Popconfirm
          v-if="canSubmit || isSubmit"
          title="确认提交该盘点任务?【提交后将不能修改】"
          v-if="isSubmit"
          title="确认开始盘点?开始后将冻结库存并可录入/导入实盘数。"
          @confirm="handleSubmit"
        >
          <Button type="primary">提交</Button>
        </Popconfirm>
        <Popconfirm
          v-if="isExecute"
          title="确认执行盘点操作?"
          @confirm="handleExecute"
        >
          <Button type="primary">执行盘点</Button>
          <Button type="primary">开始盘点</Button>
        </Popconfirm>
      </div>
    </template>
src/views/wls/transaction/data.ts
@@ -30,6 +30,11 @@
  { label: '销售退货', value: 120 },
  { label: '外协发料', value: 121 },
  { label: '外协入库', value: 122 },
  { label: '盘点调整', value: 107 },
  { label: 'SN å‡ºå…¥åº“', value: 109 },
  { label: '采购入库红冲', value: 111 },
  { label: '设备台账入库', value: 400 },
  { label: '设备入库', value: 401 },
];
/** åˆ—表的搜索表单 */