xiaoyi
21 小时以前 b2f5c965f5278a7f40d84ef88aa92fc8f8a76ca4
feat: 补齐退单管理/成本分析/执行分析/远程诊断/停机报表/风险监测前端

## 背景
后端已提交 8782476e(需求10/15/16/17/18/21/23),本次补齐对应前端界面。

## 内容

### 需求10 退单管理(新增页面)
- src/api/aftersales/cancellation.ts:受理单 8 个接口
- src/views/aftersales/cancellation/{index.vue,data.ts,modules/form.vue}
- 列表按状态控制可用操作:草稿可编辑/提交/取消/删除,待审批可审批/取消
- 审批弹窗支持通过/驳回,驳回前端先校验必填意见(后端仍兜底校验)
- 通过后提示是否生成退货单,并展示下游执行结果与失败原因

### 需求23 成本分析(新增页面)
- src/api/srm/statistics/index.ts:新增成本分析与价格趋势接口及类型
- src/views/srm/statistics/cost-analysis.vue
- 展示采购需求量、加权均价、最低/最高价、报价次数、供应商数、对预估偏差、价格变动
- 明细弹窗含按月价格趋势图与各供应商报价表,并标注计算口径

### 需求15 执行反馈
- src/api/mes/pro/feedback/index.ts:新增 getTaskExecution 及类型
- src/views/mes/pro/feedback/modules/execution.vue:计划/报工/完成率/交期偏差/延期原因/设备运行/物料消耗
- 报工列表新增「执行分析」操作(无关联任务时给出提示)

### 需求17 故障报修
- src/api/mes/dv/repair/index.ts:新增 diagnoseMachinery 及类型
- 维修页数采诊断弹窗内合并后端诊断结论(风险等级/结论/依据/指标快照),
与原有 30 秒刷新的数采快照互补;诊断失败不影响快照展示

### 需求18 OEE
- 停机汇总报表(跨设备)并入 OEE 页:停机合计、停机率、统计区间、设备数 + 明细表
- 保留原有单设备离线区间查询

### 需求21 风险监测
- src/api/safety/risk-source.ts:新增设备绑定字段与 refreshRiskSourceFromCollect
- 风险源页新增「关联设备编号」「数采指标键」表单项与「从设备采集刷新」按钮
- 列表新增「数据来源」列区分人工录入 / 设备采集;保存时校验设备与指标键配对

## 数据库(菜单)
- srm_cost_analysis_menu_20261107.sql:采购成本分析菜单
- after_sale_cancellation_20261107.sql:父菜单改为按 path=/aftersales 匹配(原按名称匹配失败)
- new_menu_role_grant_20261107.sql:按父菜单继承授权,避免新增菜单无人可见
- purchase_menu_erp_off_20261107.sql:启用「采购管理」父菜单并停用 ERP 专属子页面
(ERP 已从构建排除,原父菜单停用导致 SRM 整支不可见)

## 验证
- pnpm build:✓ built in 33.50s,生成 cancellation / cost-analysis / execution chunk
- pnpm typecheck:本次新增与改动文件 0 类型错误
- dev server 模块编译:14 个文件全部 HTTP 200,无 transform/syntax 错误
- 前端代理端到端(127.0.0.1:5666):8/8 通过
退单受理分页 total=6、成本分析 total=8、执行分析、远程诊断 level=CRITICAL、
停机报表 items=15、风险源刷新、菜单含退单管理/成本分析
- 菜单↔文件核对:239 个启用菜单组件,除 1 个历史断链
(bi/config/warehouse-coordinate,从未在 git 中存在)外全部对应
- 后端回归:主回归 21/21、需求10/23 17/17、16/22 通过
已添加6个文件
已修改8个文件
1587 ■■■■■ 文件已修改
src/api/aftersales/cancellation.ts 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/dv/repair/index.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/feedback/index.ts 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/safety/risk-source.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm/statistics/index.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/cancellation/data.ts 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/cancellation/index.vue 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/cancellation/modules/form.vue 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/repair/index.vue 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/statistics/oee.vue 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/index.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/modules/execution.vue 190 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/risk-source/index.vue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/statistics/cost-analysis.vue 272 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales/cancellation.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,109 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AftersalesCancellationApi {
  /** å—理类型 */
  export type CancellationType =
    | 'CONTRACT_CHANGE'
    | 'CONTRACT_TERMINATE'
    | 'RETURN';
  /** é€€å•/变更受理 */
  export interface Cancellation {
    id?: number;
    /** å—理单号 */
    code?: string;
    /** ç±»åž‹ï¼šRETURN é€€è´§ / CONTRACT_CHANGE åˆåŒå˜æ›´ / CONTRACT_TERMINATE åˆåŒç»ˆæ­¢ */
    type?: CancellationType;
    /** çŠ¶æ€ï¼š0 è‰ç¨¿ 1 å¾…审批 2 å·²é€šè¿‡ 3 å·²é©³å›ž 4 å·²å–消 */
    status?: number;
    ticketId?: number;
    customerId?: number;
    contractId?: number;
    saleOrderNo?: string;
    /** é€€è´§ç±»åž‹ï¼š1 é€€æ¬¾é€€è´§ 2 æ¢è´§ 3 ä»…退款 */
    returnType?: number;
    reason?: string;
    expectedAmount?: number;
    applicantId?: number;
    approverId?: number;
    approvalTime?: string;
    approvalRemark?: string;
    /** å®¡æ‰¹é€šè¿‡åŽç”Ÿæˆçš„退货单编号 */
    returnId?: number;
    /** ä¸‹æ¸¸åŠ¨ä½œæ˜¯å¦å·²æ‰§è¡Œï¼š0 æœªæ‰§è¡Œ 1 å·²æ‰§è¡Œ */
    executed?: number;
    /** ä¸‹æ¸¸æ‰§è¡Œè¯´æ˜Žæˆ–失败原因 */
    executeRemark?: string;
    remark?: string;
    creator?: string;
    createTime?: string;
  }
}
/** åˆ›å»ºé€€å•/变更受理 */
export function createCancellation(
  data: AftersalesCancellationApi.Cancellation,
) {
  return requestClient.post<number>('/aftersales/cancellation/create', data);
}
/** ä¿®æ”¹é€€å•/变更受理(仅草稿) */
export function updateCancellation(
  data: AftersalesCancellationApi.Cancellation,
) {
  return requestClient.put<boolean>('/aftersales/cancellation/update', data);
}
/** æäº¤å®¡æ‰¹ */
export function submitCancellation(id: number) {
  return requestClient.put<boolean>('/aftersales/cancellation/submit', null, {
    params: { id },
  });
}
/** å®¡æ‰¹ï¼ˆé€šè¿‡åŽæ‰§è¡Œä¸‹æ¸¸åŠ¨ä½œï¼‰ */
export function approveCancellation(data: {
  approved: boolean;
  id: number;
  remark?: string;
}) {
  return requestClient.put<number>('/aftersales/cancellation/approve', data);
}
/** å–消受理 */
export function cancelCancellation(id: number) {
  return requestClient.put<boolean>('/aftersales/cancellation/cancel', null, {
    params: { id },
  });
}
/** åˆ é™¤å—理(仅草稿) */
export function deleteCancellation(id: number) {
  return requestClient.delete<boolean>('/aftersales/cancellation/delete', {
    params: { id },
  });
}
/** èŽ·å–å—ç†è¯¦æƒ… */
export function getCancellation(id: number) {
  return requestClient.get<AftersalesCancellationApi.Cancellation>(
    '/aftersales/cancellation/get',
    { params: { id } },
  );
}
/** åˆ†é¡µæŸ¥è¯¢å—理 */
export function getCancellationPage(
  params: PageParam & {
    code?: string;
    customerId?: number;
    status?: number;
    type?: string;
  },
) {
  return requestClient.get<
    PageResult<AftersalesCancellationApi.Cancellation>
  >('/aftersales/cancellation/page', { params });
}
src/api/mes/dv/repair/index.ts
@@ -1,4 +1,4 @@
import type { PageParam, PageResult } from '@vben/request';
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
@@ -28,6 +28,34 @@
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
  /** å‘Šè­¦æ˜Žç»†é¡¹ */
  export interface DiagnosisAlarm {
    id?: number;
    message?: string;
    alarmTime?: string;
    cleared?: boolean;
  }
  /** è®¾å¤‡è¿œç¨‹è¯Šæ–­ç»“果(基于现有设备采集数据) */
  export interface Diagnosis {
    machineryId?: number;
    machineryCode?: string;
    machineryName?: string;
    hasCollectData?: boolean;
    lastCollectTime?: string;
    collectTimeout?: boolean;
    runStatus?: number;
    operateMode?: number;
    runTime?: number;
    metrics?: Record<string, number>;
    recentAlarmCount?: number;
    activeAlarmCount?: number;
    recentAlarms?: DiagnosisAlarm[];
    diagnosis?: string;
    riskLevel?: string;
    evidence?: string[];
  }
}
/** æŸ¥è¯¢ç»´ä¿®è®¢å•分页 */
@@ -42,6 +70,14 @@
export function getRepair(id: number) {
  return requestClient.get<MesDvRepairApi.Repair>(
    `/mes/dv/repair/get?id=${id}`,
  );
}
/** è®¾å¤‡è¿œç¨‹è¯Šæ–­ï¼ˆåŸºäºŽå®žæ—¶é‡‡é›† + çŠ¶æ€åŽ†å² + å‘Šè­¦ï¼‰ */
export function diagnoseMachinery(machineryId: number) {
  return requestClient.get<MesDvRepairApi.Diagnosis>(
    '/mes/dv/repair/diagnose',
    { params: { machineryId } },
  );
}
@@ -78,4 +114,4 @@
/** å®ŒæˆéªŒæ”¶ */
export function finishRepair(id: number, result: number) {
  return requestClient.put(`/mes/dv/repair/finish?id=${id}&result=${result}`);
}
}
src/api/mes/pro/feedback/index.ts
@@ -233,3 +233,54 @@
    `/mes/pro/feedback/list-by-task?taskId=${taskId}`,
  );
}
/** è®¾å¤‡è¿è¡Œæ±‡æ€»é¡¹ */
export interface TaskExecutionDeviceRun {
  machineryId?: number;
  deviceCode?: string;
  sampleCount?: number;
  totalRunTime?: number;
  lastCollectTime?: string;
  lastRunStatus?: number;
  alarmCount?: number;
}
/** ç‰©æ–™æ¶ˆè€—项 */
export interface TaskExecutionMaterial {
  itemId?: number;
  itemCode?: string;
  itemName?: string;
  quantity?: number;
  unit?: string;
}
/** ç”Ÿäº§ä»»åŠ¡æ‰§è¡Œåé¦ˆåˆ†æžï¼ˆè®¡åˆ’/报工/设备/物耗/交期偏差) */
export interface TaskExecution {
  taskId?: number;
  taskCode?: string;
  taskName?: string;
  workOrderId?: number;
  status?: number;
  plannedQuantity?: number;
  producedQuantity?: number;
  qualifyQuantity?: number;
  unqualifyQuantity?: number;
  completionRate?: number;
  startTime?: string;
  firstFeedbackTime?: string;
  lastFeedbackTime?: string;
  finishDate?: string;
  plannedDuration?: number;
  actualDuration?: number;
  delayed?: boolean;
  delayDays?: number;
  delayCauses?: string[];
  deviceRuns?: TaskExecutionDeviceRun[];
  materialConsumptions?: TaskExecutionMaterial[];
}
/** èŽ·å–ç”Ÿäº§ä»»åŠ¡æ‰§è¡Œåé¦ˆåˆ†æž */
export function getTaskExecution(taskId: number) {
  return requestClient.get<TaskExecution>('/mes/pro/feedback/execution', {
    params: { taskId },
  });
}
src/api/safety/risk-source.ts
@@ -4,8 +4,14 @@
  id?: number; name: string; category: 'MAJOR' | 'HIGH_VOLTAGE' | 'OTHER'; location?: string;
  metricName: string; unit?: string; lowerLimit?: number; upperLimit?: number;
  currentValue?: number; observedAt?: string; riskStatus?: 'NORMAL' | 'ALARM' | 'UNKNOWN'; remark?: string;
  /** å…³è”设备台账编号;为空表示仅人工录入,不接数采 */
  machineryId?: number;
  /** æ•°é‡‡æŒ‡æ ‡é”®ï¼ˆmes_collect_realtime.data_json ä¸­çš„字段名,如 current、speed) */
  metricKey?: string;
}
export function getRiskSourcePage(params: PageParam & { name?: string }) { return requestClient.get<PageResult<SafetyRiskSource>>('/safety/risk-source/page', { params }); }
export function createRiskSource(data: SafetyRiskSource) { return requestClient.post<number>('/safety/risk-source/create', data); }
export function updateRiskSource(data: SafetyRiskSource) { return requestClient.put<boolean>('/safety/risk-source/update', data); }
export function deleteRiskSource(id: number) { return requestClient.delete<boolean>('/safety/risk-source/delete', { params: { id } }); }
/** ç”¨è®¾å¤‡é‡‡é›†æ•°æ®åˆ·æ–°æ‰€æœ‰å·²ç»‘定设备的危险源,返回本次报警清单 */
export function refreshRiskSourceFromCollect() { return requestClient.post<SafetyRiskSource[]>('/safety/risk-source/refresh'); }
src/api/srm/statistics/index.ts
@@ -1,3 +1,5 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace SrmStatisticsApi {
@@ -10,9 +12,67 @@
    supplierCount?: number;
    period?: string;
  }
  /** æŠ¥ä»·æ˜Žç»†é¡¹ */
  export interface CostQuoteItem {
    supplierId?: number;
    supplierName?: string;
    quotePrice?: number;
    taxRate?: number;
    deliveryCycle?: number;
    quoteTime?: string;
  }
  /** ç‰©æ–™æˆæœ¬åˆ†æžè¡Œ */
  export interface CostAnalysis {
    productId?: number;
    productCode?: string;
    productName?: string;
    productSpec?: string;
    unit?: string;
    totalQuantity?: number;
    quoteCount?: number;
    supplierCount?: number;
    weightedAvgPrice?: number;
    minPrice?: number;
    maxPrice?: number;
    estimatedPrice?: number;
    deviationRate?: number;
    lastQuoteTime?: string;
    priceChange?: number;
    quotes?: CostQuoteItem[];
  }
  /** ä»·æ ¼è¶‹åŠ¿ç‚¹ */
  export interface CostTrendPoint {
    month?: string;
    avgPrice?: number;
    count?: number;
  }
}
/** èŽ·å–æ‹›æ ‡ç»Ÿè®¡ */
export function getTenderStatistics(year: string) {
  return requestClient.get<SrmStatisticsApi.TenderStatisticsVO>(`/srm/statistics/tender?year=${year}`);
  return requestClient.get<SrmStatisticsApi.TenderStatisticsVO>(
    '/srm/statistics/tender',
    { params: { year } },
  );
}
/** é‡‡è´­æˆæœ¬åˆ†æžï¼ˆåŸºäºŽæ‹›æ ‡éœ€æ±‚量与供应商报价,不依赖 ERP) */
export function getCostAnalysis(
  params: PageParam & { productName?: string },
) {
  return requestClient.get<PageResult<SrmStatisticsApi.CostAnalysis>>(
    '/srm/statistics/cost-analysis',
    { params },
  );
}
/** ç‰©æ–™ä»·æ ¼è¶‹åŠ¿ */
export function getCostTrend(productId: number) {
  return requestClient.get<SrmStatisticsApi.CostTrendPoint[]>(
    '/srm/statistics/cost-trend',
    { params: { productId } },
  );
}
src/views/aftersales/cancellation/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,240 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AftersalesCancellationApi } from '#/api/aftersales/cancellation';
/** è¡¨å•类型 */
export type CancellationFormType = 'create' | 'update';
/** å—理类型选项(对齐后端 AfterSaleCancellationDO.type) */
export const CANCELLATION_TYPE_OPTIONS = [
  { label: '客户退货', value: 'RETURN' },
  { label: '合同变更', value: 'CONTRACT_CHANGE' },
  { label: '合同终止', value: 'CONTRACT_TERMINATE' },
];
/** çŠ¶æ€é€‰é¡¹ */
export const CANCELLATION_STATUS_OPTIONS = [
  { label: '草稿', value: 0 },
  { label: '待审批', value: 1 },
  { label: '已通过', value: 2 },
  { label: '已驳回', value: 3 },
  { label: '已取消', value: 4 },
];
/** é€€è´§ç±»åž‹é€‰é¡¹ */
export const RETURN_TYPE_OPTIONS = [
  { label: '退款退货', value: 1 },
  { label: '换货', value: 2 },
  { label: '仅退款', value: 3 },
];
/** ç±»åž‹æ–‡æ¡ˆ */
export const CANCELLATION_TYPE_MAP: Record<string, string> =
  Object.fromEntries(
    CANCELLATION_TYPE_OPTIONS.map((o) => [o.value, o.label]),
  );
/** çŠ¶æ€æ–‡æ¡ˆ */
export const CANCELLATION_STATUS_MAP: Record<number, string> =
  Object.fromEntries(
    CANCELLATION_STATUS_OPTIONS.map((o) => [o.value, o.label]),
  );
/** çŠ¶æ€é¢œè‰² */
export const CANCELLATION_STATUS_COLOR: Record<number, string> = {
  0: 'default',
  1: 'processing',
  2: 'success',
  3: 'error',
  4: 'default',
};
/** æ˜¯å¦åˆåŒç±»å—理 */
export function isContractType(type?: string) {
  return type === 'CONTRACT_CHANGE' || type === 'CONTRACT_TERMINATE';
}
/** æœç´¢è¡¨å• */
export function useCancellationSearchSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '受理单号',
      component: 'Input',
      componentProps: { placeholder: '请输入受理单号' },
    },
    {
      fieldName: 'type',
      label: '受理类型',
      component: 'Select',
      componentProps: {
        options: CANCELLATION_TYPE_OPTIONS,
        placeholder: '请选择受理类型',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: CANCELLATION_STATUS_OPTIONS,
        placeholder: '请选择状态',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表列 */
export function useCancellationGridColumns(): VxeTableGridOptions<AftersalesCancellationApi.Cancellation>['columns'] {
  return [
    { field: 'code', title: '受理单号', width: 170 },
    {
      field: 'type',
      title: '受理类型',
      width: 110,
      formatter: ({ cellValue }) =>
        CANCELLATION_TYPE_MAP[cellValue as string] ?? '-',
    },
    {
      field: 'status',
      title: '状态',
      width: 100,
      slots: { default: 'status' },
    },
    { field: 'reason', title: '申请原因', minWidth: 200, showOverflow: 'tooltip' },
    { field: 'customerId', title: '客户编号', width: 100 },
    { field: 'contractId', title: '合同编号', width: 100 },
    {
      field: 'expectedAmount',
      title: '预计金额',
      width: 120,
      align: 'right',
    },
    {
      field: 'executed',
      title: '下游执行',
      width: 200,
      slots: { default: 'executed' },
    },
    { field: 'creator', title: '创建人', width: 110 },
    { field: 'createTime', title: '创建时间', width: 170 },
    { field: 'operation', title: '操作', width: 220, fixed: 'right', slots: { default: 'operation' } },
  ];
}
/** è¡¨å• Schema */
export function useCancellationFormSchema(
  formType: CancellationFormType,
): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { show: () => false, triggerFields: [''] },
    },
    {
      fieldName: 'type',
      label: '受理类型',
      component: 'Select',
      componentProps: {
        options: CANCELLATION_TYPE_OPTIONS,
        placeholder: '请选择受理类型',
        // å·²åˆ›å»ºçš„受理单不允许改类型,避免下游动作错配
        disabled: formType === 'update',
      },
      rules: 'required',
    },
    {
      fieldName: 'ticketId',
      label: '售后工单',
      component: 'InputNumber',
      componentProps: {
        placeholder: '退货类型必填,填工单编号',
        class: 'w-full',
        min: 1,
      },
      dependencies: {
        triggerFields: ['type'],
        show: (values) => values.type === 'RETURN',
      },
    },
    {
      fieldName: 'returnType',
      label: '退货类型',
      component: 'Select',
      componentProps: {
        options: RETURN_TYPE_OPTIONS,
        placeholder: '请选择退货类型',
      },
      dependencies: {
        triggerFields: ['type'],
        show: (values) => values.type === 'RETURN',
      },
    },
    {
      fieldName: 'contractId',
      label: 'CRM åˆåŒ',
      component: 'InputNumber',
      componentProps: {
        placeholder: '合同变更/终止必填,填合同编号',
        class: 'w-full',
        min: 1,
      },
      dependencies: {
        triggerFields: ['type'],
        show: (values) => isContractType(values.type),
      },
    },
    {
      fieldName: 'customerId',
      label: '客户编号',
      component: 'InputNumber',
      componentProps: {
        placeholder: '退货类型必填',
        class: 'w-full',
        min: 1,
      },
      dependencies: {
        triggerFields: ['type'],
        show: (values) => values.type === 'RETURN',
      },
    },
    {
      fieldName: 'saleOrderNo',
      label: '销售订单号',
      component: 'Input',
      componentProps: { placeholder: '选填' },
    },
    {
      fieldName: 'expectedAmount',
      label: '预计金额',
      component: 'InputNumber',
      componentProps: { placeholder: '选填', class: 'w-full', min: 0 },
    },
    {
      fieldName: 'reason',
      label: '申请原因',
      component: 'Textarea',
      componentProps: {
        placeholder: '请填写申请原因',
        maxlength: 500,
        showCount: true,
        rows: 3,
      },
      rules: 'required',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '选填',
        maxlength: 500,
        showCount: true,
        rows: 2,
      },
    },
  ];
}
src/views/aftersales/cancellation/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,266 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AftersalesCancellationApi } from '#/api/aftersales/cancellation';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Input, message, Modal, Tag } from 'ant-design-vue';
import { reactive, ref } from 'vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  approveCancellation,
  cancelCancellation,
  deleteCancellation,
  getCancellationPage,
  submitCancellation,
} from '#/api/aftersales/cancellation';
import {
  CANCELLATION_STATUS_COLOR,
  CANCELLATION_STATUS_MAP,
  useCancellationGridColumns,
  useCancellationSearchSchema,
} from './data';
import CancellationForm from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: CancellationForm,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleEdit(row: AftersalesCancellationApi.Cancellation) {
  formModalApi.setData({ formType: 'update', id: row.id }).open();
}
async function handleSubmit(row: AftersalesCancellationApi.Cancellation) {
  await submitCancellation(row.id!);
  message.success('已提交审批');
  handleRefresh();
}
async function handleCancel(row: AftersalesCancellationApi.Cancellation) {
  await cancelCancellation(row.id!);
  message.success('已取消');
  handleRefresh();
}
async function handleDelete(row: AftersalesCancellationApi.Cancellation) {
  await deleteCancellation(row.id!);
  message.success('删除成功');
  handleRefresh();
}
// å®¡æ‰¹å¼¹çª—(通过/驳回都要能填意见;驳回必填,前端先校验一次,后端仍会校验)
const approveVisible = ref(false);
const approveRow = ref<AftersalesCancellationApi.Cancellation>();
const approveForm = reactive<{ approved: boolean; remark: string }>({
  approved: true,
  remark: '',
});
const approving = ref(false);
function openApprove(row: AftersalesCancellationApi.Cancellation) {
  approveRow.value = row;
  approveForm.approved = true;
  approveForm.remark = '';
  approveVisible.value = true;
}
async function confirmApprove() {
  if (!approveRow.value?.id) return;
  if (!approveForm.approved && !approveForm.remark.trim()) {
    message.warning('驳回时必须填写审批意见');
    return;
  }
  approving.value = true;
  try {
    const returnId = await approveCancellation({
      id: approveRow.value.id,
      approved: approveForm.approved,
      remark: approveForm.remark.trim() || undefined,
    });
    message.success(
      approveForm.approved
        ? returnId
          ? `审批通过,已生成退货单 ${returnId}`
          : '审批通过'
        : '已驳回',
    );
    approveVisible.value = false;
    handleRefresh();
  } catch (e: unknown) {
    message.error((e as Error).message || '审批失败');
  } finally {
    approving.value = false;
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useCancellationSearchSchema(),
  },
  gridOptions: {
    columns: useCancellationGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getCancellationPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  } as VxeTableGridOptions<AftersalesCancellationApi.Cancellation>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="退单/变更受理列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新增受理',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['aftersales:cancellation:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #status="{ row }">
        <Tag :color="CANCELLATION_STATUS_COLOR[row.status as number]">
          {{ CANCELLATION_STATUS_MAP[row.status as number] ?? '-' }}
        </Tag>
      </template>
      <template #executed="{ row }">
        <div class="flex flex-col gap-1">
          <Tag :color="row.executed === 1 ? 'success' : 'warning'">
            {{ row.executed === 1 ? '已执行' : '未执行' }}
          </Tag>
          <span v-if="row.executeRemark" class="text-xs text-gray-500">
            {{ row.executeRemark }}
          </span>
        </div>
      </template>
      <template #operation="{ row }">
        <TableAction
          :actions="[
            {
              label: '编辑',
              type: 'link',
              auth: ['aftersales:cancellation:update'],
              ifShow: row.status === 0,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交审批',
              type: 'link',
              auth: ['aftersales:cancellation:submit'],
              ifShow: row.status === 0,
              popConfirm: {
                title: '确认提交审批?',
                confirm: handleSubmit.bind(null, row),
              },
            },
            {
              label: '审批',
              type: 'link',
              auth: ['aftersales:cancellation:approve'],
              ifShow: row.status === 1,
              onClick: openApprove.bind(null, row),
            },
            {
              label: '取消',
              type: 'link',
              auth: ['aftersales:cancellation:cancel'],
              ifShow: row.status === 0 || row.status === 1,
              popConfirm: {
                title: '确认取消该受理单?',
                confirm: handleCancel.bind(null, row),
              },
            },
            {
              label: '删除',
              type: 'link',
              danger: true,
              auth: ['aftersales:cancellation:delete'],
              ifShow: row.status === 0,
              popConfirm: {
                title: '确认删除该受理单?',
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
    <Modal
      v-model:open="approveVisible"
      title="审批退单/变更受理"
      :confirm-loading="approving"
      ok-text="确认"
      cancel-text="取消"
      @ok="confirmApprove"
    >
      <div class="flex flex-col gap-4 py-2">
        <div>
          <div class="mb-2 text-sm text-gray-500">审批结论</div>
          <div class="flex gap-4">
            <Button
              :type="approveForm.approved ? 'primary' : 'default'"
              @click="approveForm.approved = true"
            >
              é€šè¿‡
            </Button>
            <Button
              :danger="!approveForm.approved"
              :type="approveForm.approved ? 'default' : 'primary'"
              @click="approveForm.approved = false"
            >
              é©³å›ž
            </Button>
          </div>
        </div>
        <div>
          <div class="mb-2 text-sm text-gray-500">
            å®¡æ‰¹æ„è§
            <span v-if="!approveForm.approved" class="text-red-500">
              ï¼ˆé©³å›žå¿…填)
            </span>
          </div>
          <Input.TextArea
            v-model:value="approveForm.remark"
            :rows="3"
            :maxlength="500"
            show-count
            placeholder="请填写审批意见"
          />
        </div>
      </div>
    </Modal>
  </Page>
</template>
src/views/aftersales/cancellation/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,120 @@
<script lang="ts" setup>
import type { CancellationFormType } from '../data';
import type { AftersalesCancellationApi } from '#/api/aftersales/cancellation';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createCancellation,
  getCancellation,
  updateCancellation,
} from '#/api/aftersales/cancellation';
import { useCancellationFormSchema } from '../data';
defineOptions({ name: 'AftersalesCancellationForm' });
const emit = defineEmits(['success']);
const formType = ref<CancellationFormType>('create');
const id = ref<number>();
const submitting = ref(false);
const title = computed(() =>
  id.value ? '编辑退单/变更受理' : '新增退单/变更受理',
);
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 onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      id.value = undefined;
      return;
    }
    const data = modalApi.getData<{
      formType: CancellationFormType;
      id?: number;
    }>();
    formType.value = data.formType;
    id.value = data.id;
    formApi.setState({ schema: useCancellationFormSchema(formType.value) });
    if (data.id) {
      modalApi.lock();
      try {
        const detail = await getCancellation(data.id);
        await formApi.setValues(detail as Record<string, unknown>);
      } finally {
        modalApi.unlock();
      }
    } else {
      await formApi.resetForm();
    }
  },
});
/** æäº¤ï¼šåªæäº¤åŽç«¯ VO å£°æ˜Žçš„字段,避免把只读字段回传 */
async function handleSubmit() {
  const { valid } = await formApi.validate();
  if (!valid) {
    return;
  }
  submitting.value = true;
  try {
    const values = (await formApi.getValues()) as Record<string, unknown>;
    const payload: AftersalesCancellationApi.Cancellation = {
      type: values.type as AftersalesCancellationApi.CancellationType,
      ticketId: (values.ticketId as number) ?? undefined,
      returnType: (values.returnType as number) ?? undefined,
      contractId: (values.contractId as number) ?? undefined,
      customerId: (values.customerId as number) ?? undefined,
      saleOrderNo: (values.saleOrderNo as string) || undefined,
      expectedAmount: (values.expectedAmount as number) ?? undefined,
      reason: values.reason as string,
      remark: (values.remark as string) || undefined,
    };
    if (id.value) {
      await updateCancellation({ ...payload, id: id.value });
    } else {
      await createCancellation(payload);
    }
    message.success('操作成功');
    await modalApi.close();
    emit('success');
  } catch (e: unknown) {
    message.error((e as Error).message || '提交失败');
  } finally {
    submitting.value = false;
  }
}
</script>
<template>
  <Modal :title="title" class="w-2/3">
    <Form class="mx-4" />
    <template #footer>
      <div class="flex justify-end gap-2">
        <Button @click="modalApi.close()">取消</Button>
        <Button type="primary" :loading="submitting" @click="handleSubmit">
          ç¡®è®¤
        </Button>
      </div>
    </template>
  </Modal>
</template>
src/views/mes/dv/repair/index.vue
@@ -1,9 +1,9 @@
<script lang="ts" setup>
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesDvRepairApi } from '#/api/mes/dv/repair';
import type { MesCollectApi } from '#/api/mes/dv/collect';
import { onBeforeUnmount, ref } from 'vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { MesDvRepairStatusEnum } from '@vben/constants';
@@ -12,7 +12,12 @@
import { Alert, Button, Descriptions, message, Modal, Table } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteRepair, exportRepair, getRepairPage } from '#/api/mes/dv/repair';
import {
  deleteRepair,
  diagnoseMachinery,
  exportRepair,
  getRepairPage,
} from '#/api/mes/dv/repair';
import { getCollectAlarmPage, getCollectDeviceList } from '#/api/mes/dv/collect';
import { $t } from '#/locales';
@@ -25,6 +30,36 @@
const diagnosisRow = ref<MesDvRepairApi.Repair>();
const diagnosisDevice = ref<MesCollectApi.CollectDevice>();
const diagnosisAlarms = ref<MesCollectApi.CollectAlarm[]>([]);
/** åŽç«¯è¿œç¨‹è¯Šæ–­ç»“论(风险等级 / ç»“论 / ä¾æ®ï¼‰ï¼Œä¸Žæ•°é‡‡å¿«ç…§äº’è¡¥ */
const diagnosisResult = ref<MesDvRepairApi.Diagnosis>();
/** é£Žé™©ç­‰çº§ -> Alert ç±»åž‹ */
const RISK_ALERT: Record<string, 'error' | 'info' | 'success' | 'warning'> = {
  CRITICAL: 'error',
  NORMAL: 'success',
  UNKNOWN: 'info',
  WARNING: 'warning',
};
/** é£Žé™©ç­‰çº§ -> æ–‡æ¡ˆ */
const RISK_LABEL: Record<string, string> = {
  CRITICAL: '严重',
  NORMAL: '正常',
  UNKNOWN: '未知',
  WARNING: '注意',
};
const diagnosisAlertType = computed(
  () => RISK_ALERT[diagnosisResult.value?.riskLevel ?? 'UNKNOWN'] ?? 'info',
);
const diagnosisRiskLabel = computed(
  () => RISK_LABEL[diagnosisResult.value?.riskLevel ?? 'UNKNOWN'] ?? '未知',
);
/** æŒ‡æ ‡å¿«ç…§è½¬è¡¨æ ¼æ•°æ® */
const diagnosisMetrics = computed(() =>
  Object.entries(diagnosisResult.value?.metrics ?? {}).map(([key, value]) => ({
    key,
    value,
  })),
);
let diagnosisSequence = 0;
let diagnosisTimer: ReturnType<typeof setInterval> | undefined;
@@ -37,16 +72,26 @@
  try {
    const devices = await getCollectDeviceList();
    const device = devices.find((item) => item.machineryId === row.machineryId);
    const alarms = device?.collectDeviceCode
      ? await getCollectAlarmPage({ pageNo: 1, pageSize: 20, deviceCode: device.collectDeviceCode } as any)
      : undefined;
    // æ•°é‡‡å¿«ç…§ä¸ŽåŽç«¯è¯Šæ–­å¹¶è¡Œæ‹‰å–;诊断失败不影响快照展示
    const [alarms, result] = await Promise.all([
      device?.collectDeviceCode
        ? getCollectAlarmPage({
            pageNo: 1,
            pageSize: 20,
            deviceCode: device.collectDeviceCode,
          } as any)
        : Promise.resolve(undefined),
      diagnoseMachinery(row.machineryId).catch(() => undefined),
    ]);
    if (sequence !== diagnosisSequence) return;
    diagnosisDevice.value = device;
    diagnosisAlarms.value = alarms?.list || [];
    diagnosisResult.value = result;
  } catch {
    if (sequence === diagnosisSequence) {
      diagnosisDevice.value = undefined;
      diagnosisAlarms.value = [];
      diagnosisResult.value = undefined;
      diagnosisError.value = '设备数采或告警数据加载失败,请检查权限和数采服务。';
    }
  } finally {
@@ -59,6 +104,7 @@
  diagnosisRow.value = row;
  diagnosisDevice.value = undefined;
  diagnosisAlarms.value = [];
  diagnosisResult.value = undefined;
  diagnosisOpen.value = true;
  void refreshDiagnosis();
  if (diagnosisTimer) clearInterval(diagnosisTimer);
@@ -240,6 +286,39 @@
    <Modal v-model:open="diagnosisOpen" title="设备数采诊断" :footer="null" width="800px" @cancel="closeDiagnosis">
      <Alert class="mb-4" type="info" show-icon message="显示最近采集数据和告警,30 ç§’刷新一次;通信离线不等同于机械故障。" />
      <Alert v-if="diagnosisError" class="mb-4" type="error" show-icon :message="diagnosisError" />
      <!-- åŽç«¯è¯Šæ–­ç»“论:风险等级 + è¯Šæ–­å»ºè®® + ä¾æ® -->
      <Alert
        v-if="diagnosisResult"
        class="mb-4"
        :type="diagnosisAlertType"
        show-icon
        :message="`诊断结论(${diagnosisRiskLabel}):${diagnosisResult.diagnosis ?? '-'}`"
      />
      <div v-if="diagnosisResult" class="mb-4">
        <div class="mb-2 text-sm font-medium">诊断依据</div>
        <ul class="ml-4 list-disc text-sm text-gray-600">
          <li v-for="(item, i) in diagnosisResult.evidence ?? []" :key="i">
            {{ item }}
          </li>
          <li v-if="!(diagnosisResult.evidence ?? []).length" class="list-none text-gray-400">
            æ— 
          </li>
        </ul>
        <template v-if="diagnosisMetrics.length">
          <div class="mt-3 mb-2 text-sm font-medium">关键指标快照</div>
          <Table
            :data-source="diagnosisMetrics"
            :pagination="false"
            row-key="key"
            size="small"
            :columns="[
              { title: '指标', dataIndex: 'key' },
              { title: '值', dataIndex: 'value' },
            ]"
          />
        </template>
      </div>
      <template v-else-if="diagnosisDevice">
        <Descriptions bordered size="small" :column="2">
          <Descriptions.Item label="设备">{{ diagnosisDevice.machineryName || diagnosisRow?.machineryName || '-' }}</Descriptions.Item>
src/views/mes/dv/statistics/oee.vue
@@ -143,7 +143,63 @@
  const minutes = Math.floor(seconds / 60);
  return `${Math.floor(minutes / 60)}小时${minutes % 60}分${seconds % 60}秒`;
}
onMounted(() => { void load(); void loadCollect(); });
/** ===== åœæœºæ±‡æ€»æŠ¥è¡¨ï¼ˆè·¨è®¾å¤‡ï¼ŒæŒ‰é‡‡é›†é—´éš”æŽ¨ç®—) ===== */
interface DowntimeItem {
  machineryId: number;
  machineryCode: string;
  machineryName: string;
  deviceCode: string;
  sampleCount: number;
  downtimeCount: number;
  downtimeMinutes: number;
  intervals: { start: string; end: string; seconds: number }[];
}
interface DowntimeDetail {
  startTime: string;
  endTime: string;
  timeoutMinutes: number;
  totalMinutes: number;
  downtimeMinutes: number;
  downtimeRate: number;
  items: DowntimeItem[];
}
const downtimeRange = ref<[Dayjs, Dayjs]>([dayjs().subtract(7, 'day'), dayjs()]);
const downtimeLoading = ref(false);
const downtimeResult = ref<DowntimeDetail>();
const downtimeError = ref('');
const downtimeColumns = [
  { title: '设备编码', dataIndex: 'machineryCode' },
  { title: '设备名称', dataIndex: 'machineryName' },
  { title: '数采设备', dataIndex: 'deviceCode' },
  { title: '采样数', dataIndex: 'sampleCount' },
  { title: '停机次数', dataIndex: 'downtimeCount' },
  { title: '停机合计(分)', dataIndex: 'downtimeMinutes' },
];
async function loadDowntime() {
  downtimeLoading.value = true;
  downtimeError.value = '';
  try {
    const range = downtimeRange.value;
    downtimeResult.value = await requestClient.get<DowntimeDetail>(
      '/mes/dv/statistics/downtime',
      {
        params: {
          startTime: range[0].startOf('day').format('YYYY-MM-DD HH:mm:ss'),
          endTime: range[1].endOf('day').format('YYYY-MM-DD HH:mm:ss'),
        },
      },
    );
  } catch (e: unknown) {
    downtimeResult.value = undefined;
    // ä¿ç•™åŽç«¯ç»™å‡ºçš„可读原因(如超出保留期、区间过长)
    downtimeError.value = (e as Error).message || '停机报表加载失败';
  } finally {
    downtimeLoading.value = false;
  }
}
onMounted(() => { void load(); void loadCollect(); void loadDowntime(); });
</script>
<template>
@@ -166,6 +222,34 @@
      <Card title="设备明细">
        <Table :columns="columns" :data-source="result?.items ?? []" :loading="loading" row-key="machineryId" :scroll="{ x: 1000 }" />
      </Card>
      <Card title="停机汇总报表(跨设备)">
        <template #extra>
          <div class="flex items-center gap-2">
            <DatePicker.RangePicker v-model:value="downtimeRange" />
            <Button type="primary" :loading="downtimeLoading" @click="loadDowntime">查询</Button>
          </div>
        </template>
        <Alert class="mb-4" type="info" show-icon message="按采集间隔超过离线阈值推算停机;通信中断计入停机但不标记为机械故障。单次最多查询 31 å¤©ï¼Œä¸”不得超出数采历史保留期。" />
        <Alert v-if="downtimeError" class="mb-4" type="warning" show-icon :message="downtimeError" />
        <template v-if="downtimeResult">
          <Row :gutter="16" class="mb-4">
            <Col :span="6"><Statistic title="停机合计(分)" :value="downtimeResult.downtimeMinutes" /></Col>
            <Col :span="6"><Statistic title="停机率" :value="downtimeResult.downtimeRate" suffix="%" /></Col>
            <Col :span="6"><Statistic title="统计区间(分)" :value="downtimeResult.totalMinutes" /></Col>
            <Col :span="6"><Statistic title="设备数" :value="downtimeResult.items?.length ?? 0" /></Col>
          </Row>
          <Table :columns="downtimeColumns" :data-source="downtimeResult.items ?? []" :loading="downtimeLoading" row-key="machineryId" :scroll="{ x: 900 }" size="small" bordered>
            <template #bodyCell="{ column, record }">
              <template v-if="column.dataIndex === 'downtimeMinutes'">
                {{ record.downtimeMinutes }}
              </template>
              <template v-else-if="column.dataIndex === 'sampleCount'">
                {{ record.sampleCount }}
              </template>
            </template>
          </Table>
        </template>
      </Card>
      <Card title="设备数采实时状态">
        <template #extra><Button :loading="collectLoading" @click="loadCollect">刷新</Button></template>
        <Alert v-if="collectError" type="warning" show-icon message="数采状态加载失败,请检查数采服务后重试。" class="mb-4" />
src/views/mes/pro/feedback/index.vue
@@ -24,6 +24,20 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ArchiveDetail from '#/views/mes/pd/archive/modules/detail.vue';
import TaskExecution from './modules/execution.vue';
/** æ‰§è¡Œåé¦ˆåˆ†æžå¼¹çª— */
const executionOpen = ref(false);
const executionTaskId = ref<number>();
function openExecution(row: MesProFeedbackApi.Feedback) {
  if (!row.taskId) {
    message.warning('该报工记录未关联生产任务');
    return;
  }
  executionTaskId.value = row.taskId;
  executionOpen.value = true;
}
const userStore = useUserStore();
const currentUserId = userStore.userInfo?.id; // å½“前登录用户 ID,用于审批人权限判断
@@ -208,10 +222,18 @@
              type: 'link',
              onClick: handleArchive.bind(null, row),
            },
            {
              label: '执行分析',
              type: 'link',
              auth: ['mes:pro-feedback:query'],
              ifShow: () => !!row.taskId,
              onClick: openExecution.bind(null, row),
            },
          ]"
        />
      </template>
    </Grid>
    <TaskExecution v-model:open="executionOpen" :task-id="executionTaskId" />
    <ArchiveDetail ref="archiveDetailRef" />
  </Page>
</template>
src/views/mes/pro/feedback/modules/execution.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,190 @@
<script lang="ts" setup>
import type { TaskExecution } from '#/api/mes/pro/feedback';
import { computed, ref, watch } from 'vue';
import {
  Alert,
  Descriptions,
  message,
  Modal,
  Spin,
  Statistic,
  Table,
  Tag,
} from 'ant-design-vue';
import { getTaskExecution } from '#/api/mes/pro/feedback';
defineOptions({ name: 'MesProTaskExecution' });
const props = withDefaults(
  defineProps<{ open?: boolean; taskId?: number }>(),
  { open: false, taskId: undefined },
);
const emit = defineEmits(['update:open']);
const loading = ref(false);
const data = ref<TaskExecution>();
const deviceColumns = [
  { title: '设备编号', dataIndex: 'machineryId' },
  { title: '数采设备', dataIndex: 'deviceCode' },
  { title: '采样数', dataIndex: 'sampleCount' },
  { title: '累计运行时长', dataIndex: 'totalRunTime' },
  { title: '最近采集', dataIndex: 'lastCollectTime' },
  { title: '告警数', dataIndex: 'alarmCount' },
];
const materialColumns = [
  { title: '物料编码', dataIndex: 'itemCode' },
  { title: '物料名称', dataIndex: 'itemName' },
  { title: '消耗数量', dataIndex: 'quantity' },
  { title: '单位', dataIndex: 'unit' },
];
function fmtQty(v?: number) {
  return v === null || v === undefined ? '-' : Number(v).toFixed(2);
}
function fmtDuration(minutes?: number) {
  if (minutes === null || minutes === undefined) return '-';
  const h = Math.floor(minutes / 60);
  const m = minutes % 60;
  return `${h}小时${m}分`;
}
const delayText = computed(() => {
  if (!data.value?.delayed) return '未延期';
  return `延期 ${data.value.delayDays ?? 0} å¤©`;
});
async function load() {
  if (!props.taskId) return;
  loading.value = true;
  try {
    data.value = await getTaskExecution(props.taskId);
  } catch (e: unknown) {
    message.error((e as Error).message || '加载执行分析失败');
    data.value = undefined;
  } finally {
    loading.value = false;
  }
}
watch(
  () => [props.open, props.taskId] as const,
  ([open]) => {
    if (open) {
      data.value = undefined;
      load();
    }
  },
  { immediate: true },
);
</script>
<template>
  <Modal
    :open="open"
    title="生产任务执行反馈分析"
    width="920px"
    :footer="null"
    @cancel="emit('update:open', false)"
  >
    <Spin :spinning="loading">
      <div v-if="data" class="flex flex-col gap-4">
        <Alert
          :type="data.delayed ? 'warning' : 'success'"
          show-icon
          :message="`${data.taskCode ?? ''} ${data.taskName ?? ''} â€”— ${delayText}`"
        />
        <div class="grid grid-cols-4 gap-4">
          <Statistic title="计划数量" :value="fmtQty(data.plannedQuantity)" />
          <Statistic title="实际报工" :value="fmtQty(data.producedQuantity)" />
          <Statistic
            title="完成率"
            :value="data.completionRate ?? 0"
            suffix="%"
          />
          <Statistic
            title="延期天数"
            :value="data.delayed ? (data.delayDays ?? 0) : 0"
          />
        </div>
        <Descriptions bordered size="small" :column="3">
          <Descriptions.Item label="计划开始">
            {{ data.startTime ?? '-' }}
          </Descriptions.Item>
          <Descriptions.Item label="首次报工">
            {{ data.firstFeedbackTime ?? '-' }}
          </Descriptions.Item>
          <Descriptions.Item label="末次报工">
            {{ data.lastFeedbackTime ?? '-' }}
          </Descriptions.Item>
          <Descriptions.Item label="计划完成">
            {{ data.finishDate ?? '-' }}
          </Descriptions.Item>
          <Descriptions.Item label="计划工时">
            {{ fmtDuration(data.plannedDuration) }}
          </Descriptions.Item>
          <Descriptions.Item label="实际耗时">
            {{ fmtDuration(data.actualDuration) }}
          </Descriptions.Item>
          <Descriptions.Item label="合格数量">
            {{ fmtQty(data.qualifyQuantity) }}
          </Descriptions.Item>
          <Descriptions.Item label="报废数量">
            {{ fmtQty(data.unqualifyQuantity) }}
          </Descriptions.Item>
          <Descriptions.Item label="任务状态">
            {{ data.status ?? '-' }}
          </Descriptions.Item>
        </Descriptions>
        <div v-if="(data.delayCauses ?? []).length">
          <div class="mb-2 font-medium">延期原因(可观测信号,需人工确认)</div>
          <div class="flex flex-wrap gap-2">
            <Tag v-for="(c, i) in data.delayCauses ?? []" :key="i" color="orange">
              {{ c }}
            </Tag>
          </div>
        </div>
        <div>
          <div class="mb-2 font-medium">设备运行数据</div>
          <Table
            size="small"
            bordered
            :pagination="false"
            :data-source="data.deviceRuns ?? []"
            :columns="deviceColumns"
            row-key="machineryId"
          />
        </div>
        <div>
          <div class="mb-2 font-medium">物料消耗</div>
          <Table
            size="small"
            bordered
            :pagination="false"
            :data-source="data.materialConsumptions ?? []"
            :columns="materialColumns"
            row-key="itemId"
          />
        </div>
        <Alert
          type="info"
          show-icon
          message="实际耗时按首次到末次报工计算;延期原因仅列可观测信号,不推断管理原因。"
        />
      </div>
      <div v-else-if="!loading" class="py-8 text-center text-gray-400">
        æš‚无执行数据
      </div>
    </Spin>
  </Modal>
</template>
src/views/safety/risk-source/index.vue
@@ -3,36 +3,52 @@
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Alert, Button, Card, Form, FormItem, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag } from 'ant-design-vue';
import { createRiskSource, deleteRiskSource, getRiskSourcePage, updateRiskSource } from '#/api/safety/risk-source';
import { createRiskSource, deleteRiskSource, getRiskSourcePage, refreshRiskSourceFromCollect, updateRiskSource } from '#/api/safety/risk-source';
defineOptions({ name: 'SafetyRiskSource' });
const rows=ref<SafetyRiskSource[]>([]); const loading=ref(false); const saving=ref(false); const open=ref(false);
const filters=reactive<{name?:string}>({}); const paging=reactive({current:1,pageSize:10,total:0});
const blank=():SafetyRiskSource=>({name:'',category:'MAJOR',location:'',metricName:'',unit:'',lowerLimit:undefined,upperLimit:undefined,currentValue:undefined,remark:''});
const blank=():SafetyRiskSource=>({name:'',category:'MAJOR',location:'',metricName:'',unit:'',lowerLimit:undefined,upperLimit:undefined,currentValue:undefined,remark:'',machineryId:undefined,metricKey:''});
const model=reactive<SafetyRiskSource>(blank());
const categories:Record<string,string>={MAJOR:'重大危险源',HIGH_VOLTAGE:'高压试验区域',OTHER:'其他'};
const states:Record<string,string>={NORMAL:'正常',ALARM:'超限',UNKNOWN:'未知'};
const columns=[{title:'危险源名称',dataIndex:'name'},{title:'类别',key:'category'},{title:'位置',dataIndex:'location'},
 {title:'监测指标',dataIndex:'metricName'},{title:'当前值',key:'value'},{title:'阈值',key:'limits'},
 {title:'监测指标',dataIndex:'metricName'},{title:'数据来源',key:'source'},{title:'当前值',key:'value'},{title:'阈值',key:'limits'},
 {title:'数值变更时间',dataIndex:'observedAt'},{title:'状态',key:'status'},{title:'操作',key:'action',width:150}];
async function load(reset=false){if(reset)paging.current=1;loading.value=true;try{const r=await getRiskSourcePage({pageNo:paging.current,pageSize:paging.pageSize,...filters});rows.value=r.list;paging.total=r.total;}finally{loading.value=false;}}
function edit(row?:SafetyRiskSource){Object.assign(model,blank(),{id:undefined},row||{});open.value=true;}
async function save(){
 if(!model.name.trim()||!model.metricName.trim()){message.warning('请填写危险源名称和监测指标');return;}
 if(model.lowerLimit!=null&&model.upperLimit!=null&&model.lowerLimit>model.upperLimit){message.warning('下限不能大于上限');return;}
 if(model.machineryId&&!model.metricKey?.trim()){message.warning('绑定设备后必须填写数采指标键');return;}
 if(!model.machineryId&&model.metricKey?.trim()){message.warning('填写数采指标键前请先绑定设备');return;}
 saving.value=true;try{if(model.id)await updateRiskSource(model);else await createRiskSource(model);message.success('保存成功');open.value=false;await load();}finally{saving.value=false;}
}
async function remove(id:number){await deleteRiskSource(id);message.success('已删除');await load();}
/** ä»Žè®¾å¤‡é‡‡é›†åˆ·æ–°å·²ç»‘定设备的危险源 */
const refreshing=ref(false);
async function refreshFromCollect(){
 if(refreshing.value)return;
 refreshing.value=true;
 try{
  const alarms=await refreshRiskSourceFromCollect();
  const count=alarms?.length??0;
  if(count>0){message.warning(`已刷新,${count} ä¸ªå±é™©æºè¶…限:${alarms.map(a=>a.name).join('、')}`);}
  else{message.success('已刷新,暂无超限危险源');}
  await load();
 }finally{refreshing.value=false;}
}
onMounted(()=>load());
</script>
<template>
 <Page auto-content-height>
  <Card title="危险源台账">
   <Alert class="mb-4" type="info" show-icon message="人工维护当前监测值和阈值;此页面不代表现场自动采集,亦不保存历史观测序列。" />
   <Space class="mb-3"><Input v-model:value="filters.name" placeholder="危险源名称" allow-clear /><Button @click="load(true)">查询</Button><Button v-access:code="['safety:risk:create']" type="primary" @click="edit()">新增危险源</Button></Space>
   <Alert class="mb-4" type="info" show-icon message="未绑定设备的危险源由人工维护当前值;绑定设备并填写数采指标键后,可点「从设备采集刷新」自动取值并按阈值判定,不保存历史观测序列。" />
   <Space class="mb-3"><Input v-model:value="filters.name" placeholder="危险源名称" allow-clear /><Button @click="load(true)">查询</Button><Button v-access:code="['safety:risk:create']" type="primary" @click="edit()">新增危险源</Button><Button v-access:code="['safety:risk:update']" :loading="refreshing" @click="refreshFromCollect">从设备采集刷新</Button></Space>
   <Table :columns="columns" :data-source="rows" :loading="loading" :pagination="paging" row-key="id" :scroll="{x:1100}" @change="p=>{paging.current=p.current||1;paging.pageSize=p.pageSize||10;load();}">
    <template #bodyCell="{column,record}">
     <template v-if="column.key==='category'">{{categories[record.category]||record.category}}</template>
     <template v-else-if="column.key==='source'"><Tag :color="record.machineryId?'blue':'default'">{{record.machineryId?`设备采集(${record.metricKey||'-'})`:'人工录入'}}</Tag></template>
     <template v-else-if="column.key==='value'">{{record.currentValue??'未录入'}} {{record.unit||''}}</template>
     <template v-else-if="column.key==='limits'">{{record.lowerLimit??'不限'}} ï½ž {{record.upperLimit??'不限'}} {{record.unit||''}}</template>
     <Tag v-else-if="column.key==='status'" :color="record.riskStatus==='ALARM'?'red':record.riskStatus==='NORMAL'?'green':'default'">{{states[record.riskStatus]||'未知'}}</Tag>
@@ -49,6 +65,12 @@
    <FormItem label="单位"><Input v-model:value="model.unit" :maxlength="32" /></FormItem>
    <FormItem label="下限(可空)"><InputNumber v-model:value="model.lowerLimit" class="w-full" /></FormItem>
    <FormItem label="上限(可空)"><InputNumber v-model:value="model.upperLimit" class="w-full" /></FormItem>
    <FormItem label="关联设备编号(可空)" extra="填写后由数采自动取值,人工当前值将被覆盖">
     <InputNumber v-model:value="model.machineryId" class="w-full" :min="1" placeholder="设备台账编号" />
    </FormItem>
    <FormItem label="数采指标键(可空)" extra="取 mes_collect_realtime.data_json ä¸­çš„字段名,如 current、speed;也可用 ot/sts/opm">
     <Input v-model:value="model.metricKey" :maxlength="64" placeholder="如 current" />
    </FormItem>
    <FormItem label="当前值(人工录入)"><InputNumber v-model:value="model.currentValue" class="w-full" /></FormItem>
    <FormItem label="备注"><Input.TextArea v-model:value="model.remark" :maxlength="500" /></FormItem>
   </Form>
src/views/srm/statistics/cost-analysis.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,272 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SrmStatisticsApi } from '#/api/srm/statistics';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Descriptions, message, Modal, Spin, Table, Tag } from 'ant-design-vue';
import { ref } from 'vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCostAnalysis, getCostTrend } from '#/api/srm/statistics';
defineOptions({ name: 'SrmCostAnalysis' });
/** æ•°å­—格式化:空值显示 - */
function fmt(value?: number, digits = 2) {
  return value === null || value === undefined
    ? '-'
    : Number(value).toFixed(digits);
}
/** è¶‹åŠ¿å›¾ */
const trendChartRef = ref();
const { renderEcharts } = useEcharts(trendChartRef);
/** æ˜Žç»†å¼¹çª— */
const detailVisible = ref(false);
const detailRow = ref<SrmStatisticsApi.CostAnalysis>();
const trendLoading = ref(false);
async function openDetail(row: SrmStatisticsApi.CostAnalysis) {
  detailRow.value = row;
  detailVisible.value = true;
  await loadTrend(row.productId);
}
async function loadTrend(productId?: number) {
  if (!productId) {
    await renderEcharts({});
    return;
  }
  trendLoading.value = true;
  try {
    const points = await getCostTrend(productId);
    const months = (points ?? []).map((p) => p.month ?? '');
    const prices = (points ?? []).map((p) => p.avgPrice ?? 0);
    await renderEcharts({
      title: { text: '物料报价趋势(按月均价)', left: 'center' },
      tooltip: { trigger: 'axis' },
      grid: { left: 60, right: 30, bottom: 40, top: 60 },
      xAxis: { type: 'category', data: months },
      yAxis: { type: 'value' },
      series: [{ type: 'line', smooth: true, data: prices, name: '平均报价' }],
    });
  } catch (e: unknown) {
    message.error((e as Error).message || '加载趋势失败');
  } finally {
    trendLoading.value = false;
  }
}
const columns: VxeTableGridOptions<SrmStatisticsApi.CostAnalysis>['columns'] = [
  { field: 'productCode', title: '物料编码', width: 140 },
  { field: 'productName', title: '物料名称', minWidth: 160 },
  { field: 'productSpec', title: '规格', width: 140, showOverflow: 'tooltip' },
  { field: 'unit', title: '单位', width: 80 },
  {
    field: 'totalQuantity',
    title: '采购需求量',
    width: 120,
    align: 'right',
    formatter: ({ cellValue }) => fmt(cellValue as number, 2),
  },
  {
    field: 'weightedAvgPrice',
    title: '加权均价',
    width: 120,
    align: 'right',
    formatter: ({ cellValue }) => fmt(cellValue as number, 4),
  },
  {
    field: 'minPrice',
    title: '最低价',
    width: 100,
    align: 'right',
    formatter: ({ cellValue }) => fmt(cellValue as number, 4),
  },
  {
    field: 'maxPrice',
    title: '最高价',
    width: 100,
    align: 'right',
    formatter: ({ cellValue }) => fmt(cellValue as number, 4),
  },
  { field: 'quoteCount', title: '报价次数', width: 100, align: 'right' },
  { field: 'supplierCount', title: '供应商数', width: 100, align: 'right' },
  {
    field: 'deviationRate',
    title: '对预估偏差',
    width: 120,
    align: 'right',
    slots: { default: 'deviation' },
  },
  {
    field: 'priceChange',
    title: '价格变动',
    width: 110,
    align: 'right',
    slots: { default: 'priceChange' },
  },
  {
    field: 'operation',
    title: '操作',
    width: 100,
    fixed: 'right',
    slots: { default: 'operation' },
  },
];
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: [
      {
        fieldName: 'productName',
        label: '物料名称',
        component: 'Input',
        componentProps: { placeholder: '请输入物料名称' },
      },
    ],
  },
  gridOptions: {
    columns,
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async (
          params: { page: { currentPage: number; pageSize: number } },
          formValues: Record<string, any>,
        ) => {
          const { page } = params;
          try {
            return await getCostAnalysis({
              pageNo: page.currentPage,
              pageSize: page.pageSize,
              ...formValues,
            });
          } catch (e: unknown) {
            message.error((e as Error).message || '查询失败');
            return { list: [], total: 0 };
          }
        },
      },
    },
    rowConfig: { keyField: 'productId', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  },
});
</script>
<template>
  <Page auto-content-height>
    <Grid table-title="采购成本分析(按招标需求量与供应商报价)">
      <template #deviation="{ row }">
        <span
          :class="
            row.deviationRate > 0
              ? 'text-red-500'
              : row.deviationRate < 0
                ? 'text-green-600'
                : ''
          "
        >
          {{ row.deviationRate === null || row.deviationRate === undefined
            ? '-'
            : `${row.deviationRate}%` }}
        </span>
      </template>
      <template #priceChange="{ row }">
        <span
          :class="
            row.priceChange > 0
              ? 'text-red-500'
              : row.priceChange < 0
                ? 'text-green-600'
                : ''
          "
        >
          {{ row.priceChange === null || row.priceChange === undefined
            ? '-'
            : fmt(row.priceChange, 4) }}
        </span>
      </template>
      <template #operation="{ row }">
        <a @click="openDetail(row)">查看明细</a>
      </template>
    </Grid>
    <Modal
      v-model:open="detailVisible"
      title="报价明细"
      :footer="null"
      width="900px"
    >
      <div v-if="detailRow" class="flex flex-col gap-4">
        <Descriptions bordered size="small" :column="3">
          <Descriptions.Item label="物料编码">
            {{ detailRow.productCode ?? '-' }}
          </Descriptions.Item>
          <Descriptions.Item label="物料名称">
            {{ detailRow.productName ?? '-' }}
          </Descriptions.Item>
          <Descriptions.Item label="规格">
            {{ detailRow.productSpec ?? '-' }}
          </Descriptions.Item>
          <Descriptions.Item label="采购需求量">
            {{ fmt(detailRow.totalQuantity) }}
            {{ detailRow.unit ?? '' }}
          </Descriptions.Item>
          <Descriptions.Item label="加权均价">
            {{ fmt(detailRow.weightedAvgPrice, 4) }}
          </Descriptions.Item>
          <Descriptions.Item label="预估价">
            {{ fmt(detailRow.estimatedPrice, 4) }}
          </Descriptions.Item>
          <Descriptions.Item label="最低价">
            {{ fmt(detailRow.minPrice, 4) }}
          </Descriptions.Item>
          <Descriptions.Item label="最高价">
            {{ fmt(detailRow.maxPrice, 4) }}
          </Descriptions.Item>
          <Descriptions.Item label="最近报价时间">
            {{ detailRow.lastQuoteTime ?? '-' }}
          </Descriptions.Item>
        </Descriptions>
        <Spin :spinning="trendLoading">
          <EchartsUI ref="trendChartRef" height="260px" />
        </Spin>
        <Table
          size="small"
          bordered
          :pagination="false"
          :data-source="detailRow.quotes ?? []"
          :row-key="(r: SrmStatisticsApi.CostQuoteItem) => `${r.supplierId}-${r.quoteTime ?? ''}`"
        >
          <Table.Column title="供应商" data-index="supplierName" key="supplierName" />
          <Table.Column title="报价单价" key="quotePrice">
            <template #default="{ record }">
              {{ fmt(record.quotePrice, 4) }}
            </template>
          </Table.Column>
          <Table.Column title="税率(%)" key="taxRate">
            <template #default="{ record }">
              {{ record.taxRate ?? '-' }}
            </template>
          </Table.Column>
          <Table.Column title="交期(天)" data-index="deliveryCycle" key="deliveryCycle" />
          <Table.Column title="报价时间" data-index="quoteTime" key="quoteTime" />
        </Table>
      </div>
      <div class="mt-3">
        <Tag color="blue">
          å£å¾„:加权均价 = Î£(报价单价 Ã— éœ€æ±‚数量) / Î£(需求数量)
        </Tag>
      </div>
    </Modal>
  </Page>
</template>