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 通过
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }); |
| | | } |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | |
| | | 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[]; |
| | | } |
| | | } |
| | | |
| | | /** æ¥è¯¢ç»´ä¿®è®¢åå页 */ |
| | |
| | | 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 } }, |
| | | ); |
| | | } |
| | | |
| | |
| | | /** å®æéªæ¶ */ |
| | | export function finishRepair(id: number, result: number) { |
| | | return requestClient.put(`/mes/dv/repair/finish?id=${id}&result=${result}`); |
| | | } |
| | | } |
| | |
| | | `/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 }, |
| | | }); |
| | | } |
| | |
| | | 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'); } |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace SrmStatisticsApi { |
| | |
| | | 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 } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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, |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | |
| | | <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'; |
| | |
| | | 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'; |
| | | |
| | |
| | | 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; |
| | | |
| | |
| | | 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 { |
| | |
| | | diagnosisRow.value = row; |
| | | diagnosisDevice.value = undefined; |
| | | diagnosisAlarms.value = []; |
| | | diagnosisResult.value = undefined; |
| | | diagnosisOpen.value = true; |
| | | void refreshDiagnosis(); |
| | | if (diagnosisTimer) clearInterval(diagnosisTimer); |
| | |
| | | <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> |
| | |
| | | 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> |
| | |
| | | <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" /> |
| | |
| | | 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ï¼ç¨äºå®¡æ¹äººæé夿 |
| | |
| | | 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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | |
| | | 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> |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |