feat(wms): 重构仓库管理系统并优化图表显示
- 添加MES仓库类型枚举定义,支持暂存库和合格库
- 实现农业主题色彩方案,统一绿色系视觉风格
- 重构销售相关业务逻辑,移除销售订单关联功能
- 更新首页统计面板数据结构,增加多种业务指标
- 优化图表配置,提升数据可视化效果和一致性
- 调整扫码事件API接口,规范扫码类型参数
- 完善发货通知单库存管理,限制从合格库选择库存
- 优化退货单处理流程,简化物料选择逻辑
- 移除冗余组件文件,精简代码结构
- 调整表格列宽配置,优化用户界面布局
已删除2个文件
已添加12个文件
已修改37个文件
| | |
| | | "cropperjs": "catalog:", |
| | | "dayjs": "catalog:", |
| | | "dhtmlx-gantt": "catalog:", |
| | | "echarts": "catalog:", |
| | | "diagram-js": "catalog:", |
| | | "fast-xml-parser": "catalog:", |
| | | "gsap": "^3.15.0", |
| | |
| | | diagram-js: |
| | | specifier: 'catalog:' |
| | | version: 15.17.0 |
| | | echarts: |
| | | specifier: 'catalog:' |
| | | version: 6.1.0 |
| | | fast-xml-parser: |
| | | specifier: 'catalog:' |
| | | version: 4.5.6 |
| | |
| | | |
| | | // è¡¨æ ¼é
置项å¯ä»¥ç¨ cellRender: { name: 'CellTag' }, |
| | | // props: { data: [{value, color}], labels?: {[value]: label} } |
| | | // ä¹å
¼å®¹ options æ ¼å¼: [{ label, value, color }] |
| | | // æ¯æä¸¤ç§å¹é
模å¼: |
| | | // 1. 精确å¹é
: data å labels ä¸é½æ¯ç²¾ç¡®å¼ (ç¶æ/ç±»åæä¸¾) |
| | | // 1. 精确å¹é
: data/labels/options ä¸é½æ¯ç²¾ç¡®å¼ (ç¶æ/ç±»åæä¸¾) |
| | | // 2. éå¼å¹é
: data 为éåºéå¼, label æ¾ç¤ºåå§æ°å¼ (è¯å) |
| | | vxeUI.renderer.add('CellTag', { |
| | | renderTableDefault(renderOpts, params) { |
| | |
| | | const { column, row } = params; |
| | | const rawValue = row[column.field]; |
| | | |
| | | // å
¼å®¹ options æ ¼å¼ï¼ç²¾ç¡®å¹é
æ¾ç¤ºææ¬ä¸é¢è² |
| | | const matchedOption = props?.options?.find( |
| | | (item: any) => |
| | | item.value === rawValue || Number(item.value) === Number(rawValue), |
| | | ); |
| | | |
| | | // æ¥æ¾æ¾ç¤ºææ¬ |
| | | let label = rawValue; |
| | | if (props?.labels && rawValue !== undefined && rawValue !== null) { |
| | | if (matchedOption) { |
| | | label = matchedOption.label; |
| | | } else if (props?.labels && rawValue !== undefined && rawValue !== null) { |
| | | label = props.labels[rawValue] ?? rawValue; |
| | | } |
| | | |
| | | // æ¥æ¾é¢è²: ä¼å
精确å¹é
ï¼å¦åéå¼å¹é
|
| | | let color: string | undefined; |
| | | if (props?.data) { |
| | | if (matchedOption) { |
| | | color = matchedOption.color; |
| | | } else if (props?.data) { |
| | | const exact = props.data.find( |
| | | (item: any) => item.value === rawValue, |
| | | ); |
| | |
| | | needProduction?: number; // æ¯å¦éè¦ç产ï¼0-ä¸éè¦ï¼1-éè¦ |
| | | productionStatus?: number; // çäº§ç¶æï¼0-æªå®æï¼1-已宿 |
| | | productionFinishTime?: Date; // çäº§å®ææ¶é´ |
| | | hasSalesNotice?: boolean; // æ¯å¦å·²çæåè´§éç¥å |
| | | canCreateSalesNotice?: boolean; // æ¯å¦å¯ä»¥çæåè´§éç¥å |
| | | items?: SaleOrderItem[]; // éå®è®¢å产åæç»å表 |
| | | } |
| | | |
| | |
| | | batchCount: number; // æ¹æ¬¡æ»æ° |
| | | bagCodeCount: number; // è¢ç æ»æ° |
| | | palletCount: number; // æçç æ»æ° |
| | | salesNoticeCount: number; // åè´§éç¥åæ° |
| | | productSalesCount: number; // éå®åºåºåæ° |
| | | returnSalesCount: number; // éå®éè´§åæ° |
| | | stockWarningPendingCount: number; // æªå¤çåºåé¢è¦æ° |
| | | ncrPendingCount: number; // å¾
è¯å®¡ä¸åæ ¼ååæ° |
| | | batchStatusList: StatusItem[]; // æ¹æ¬¡ç¶æåå¸ |
| | | bagCodeStatusList: StatusItem[]; // è¢ç ç¶æåå¸ |
| | | } |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace MesProScanEventApi { |
| | | /** æ«ç ç±»åï¼è¢ç / æçç / æ¹æ¬¡ç */ |
| | | export type ScanType = 'BAG' | 'PALLET' | 'BATCH'; |
| | | |
| | | export interface ScanEvent { |
| | | id?: number; |
| | | eventId?: string; |
| | | bagCode?: string; |
| | | scanType?: ScanType; |
| | | batchCode?: string; |
| | | quantity?: number; |
| | | deviceCode?: string; |
| | |
| | | export interface InboundParams { |
| | | eventId: string; |
| | | bagCode: string; |
| | | scanType: ScanType; |
| | | quantity: number; |
| | | deviceCode: string; |
| | | deviceCode?: string; |
| | | lineCode?: string; |
| | | locationId: number; |
| | | areaId: number; |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export interface SeedQualityPageParams { |
| | | pageNo?: number; |
| | | pageSize?: number; |
| | | code?: string; |
| | | batchCode?: string; |
| | | result?: number; |
| | | } |
| | | |
| | | export interface SeedQualityRecord { |
| | | id: number; |
| | | code: string; |
| | | batchCode?: string; |
| | | result: number; |
| | | reason?: string; |
| | | inspectorId?: number; |
| | | inspectedAt?: string; |
| | | createTime?: string; |
| | | } |
| | | |
| | | export namespace MesQcSeedQualityApi { |
| | | export interface Indicator { |
| | | code: string; |
| | |
| | | |
| | | export interface SubmitParams { |
| | | code: string; |
| | | itemId: number; |
| | | batchCode?: string; |
| | | indicators: Indicator[]; |
| | | reason?: string; |
| | | qualifiedWarehouseId: number; // ç®æ åæ ¼åºï¼è´¨æ£åæ ¼åå
¥è¯¥ä»åºï¼ä»åºç±»åä¸ºåæ ¼åº20ï¼ |
| | | } |
| | | } |
| | | |
| | | export function getSeedQualityPage(params: SeedQualityPageParams) { |
| | | return requestClient.get<{ list: SeedQualityRecord[]; total: number }>('/mes/qc/seed-quality/page', { params }); |
| | | } |
| | | |
| | | export function submitSeedQuality(data: MesQcSeedQualityApi.SubmitParams) { |
| | | return requestClient.post<number>('/mes/qc/seed-quality/submit', data); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace MesTraceCrossRegionApi { |
| | | /** çªè´§é¢è¦æ±æ» */ |
| | | export interface Summary { |
| | | total?: number; // é¢è¦æ»æ° |
| | | pending?: number; // å¾
å¤çæ°é |
| | | handled?: number; // å·²å¤çæ°é |
| | | falseAlarm?: number; // è¯¯æ¥æ°é |
| | | crossProvince?: number; // è·¨ççªè´§æ°é |
| | | crossCity?: number; // è·¨å¸çªè´§æ°é |
| | | } |
| | | } |
| | | |
| | | /** è·å¾çªè´§é¢è¦æ±æ» */ |
| | | export function getTraceCrossRegionSummary(startTime?: string, endTime?: string) { |
| | | return requestClient.get<MesTraceCrossRegionApi.Summary>( |
| | | '/mes/trace-cross-region/summary', |
| | | { params: { startTime, endTime } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace MesTraceBatchApi { |
| | | export interface Worker { |
| | | workerType?: number; |
| | | workerTypeName?: string; |
| | | userName?: string; |
| | | remark?: string; |
| | | } |
| | | |
| | | export interface Pallet { |
| | | code?: string; |
| | | palletNo?: number; |
| | | bagCount?: number; |
| | | capacity?: number; |
| | | } |
| | | |
| | | export interface Bag { |
| | | code?: string; |
| | | bagNo?: number; |
| | | palletCode?: string; |
| | | status?: number; |
| | | statusName?: string; |
| | | inboundTime?: string; |
| | | } |
| | | |
| | | export interface Inbound { |
| | | eventId?: string; |
| | | deviceCode?: string; |
| | | lineCode?: string; |
| | | quantity?: number; |
| | | status?: string; |
| | | eventTime?: string; |
| | | } |
| | | |
| | | export interface Indicator { |
| | | indicatorCode?: string; |
| | | indicatorName?: string; |
| | | value?: number; |
| | | minValue?: number; |
| | | maxValue?: number; |
| | | pass?: boolean; |
| | | } |
| | | |
| | | export interface Quality { |
| | | result?: number; |
| | | resultName?: string; |
| | | reason?: string; |
| | | inspectorName?: string; |
| | | inspectedAt?: string; |
| | | indicators?: Indicator[]; |
| | | } |
| | | |
| | | export interface PurchaseSource { |
| | | receiptCode?: string; |
| | | receiptDate?: string; |
| | | purchaseOrderCode?: string; |
| | | vendorCode?: string; |
| | | vendorName?: string; |
| | | iqcCode?: string; |
| | | checkResult?: number; |
| | | itemCode?: string; |
| | | itemName?: string; |
| | | receivedQuantity?: number; |
| | | } |
| | | |
| | | export interface SalesTarget { |
| | | salesCode?: string; |
| | | salesDate?: string; |
| | | salesOrderCode?: string; |
| | | clientName?: string; |
| | | oqcCode?: string; |
| | | checkResult?: number; |
| | | itemCode?: string; |
| | | itemName?: string; |
| | | quantity?: number; |
| | | qualityStatus?: number; |
| | | } |
| | | |
| | | export interface ProcessTrace { |
| | | eventType?: 'OUT' | 'IN'; |
| | | workOrderCode?: string; |
| | | processCode?: string; |
| | | processName?: string; |
| | | quantity?: number; |
| | | qualityStatus?: number; |
| | | eventTime?: string; |
| | | } |
| | | |
| | | export interface Timeline { |
| | | stage?: string; |
| | | eventType?: string; |
| | | eventName?: string; |
| | | eventTime?: string; |
| | | quantity?: number; |
| | | status?: string; |
| | | } |
| | | |
| | | export interface Decision { |
| | | qualityRisk?: boolean; |
| | | inboundQuantity?: number; |
| | | salesTargetCount?: number; |
| | | consumerScanCount?: number; |
| | | riskLevel?: string; |
| | | } |
| | | |
| | | export interface Detail { |
| | | sourceType?: string; |
| | | requestCode?: string; |
| | | itemCode?: string; |
| | | itemName?: string; |
| | | itemSpecification?: string; |
| | | batchCode?: string; |
| | | tempCode?: string; |
| | | batchStatus?: number; |
| | | batchStatusName?: string; |
| | | produceDate?: string; |
| | | lineCode?: string; |
| | | lineName?: string; |
| | | ownerUserName?: string; |
| | | planQuantity?: number; |
| | | bagQuantity?: number; |
| | | palletQuantity?: number; |
| | | inboundTime?: string; |
| | | remark?: string; |
| | | workers?: Worker[]; |
| | | palletCount?: number; |
| | | pallets?: Pallet[]; |
| | | bagTotal?: number; |
| | | bags?: Bag[]; |
| | | inbound?: Inbound; |
| | | quality?: Quality; |
| | | antiFake?: { |
| | | scanCount?: number; |
| | | firstScanTime?: string; |
| | | lastScanTime?: string; |
| | | }; |
| | | purchaseSources?: PurchaseSource[]; |
| | | salesTargets?: SalesTarget[]; |
| | | processTraces?: ProcessTrace[]; |
| | | timeline?: Timeline[]; |
| | | decision?: Decision; |
| | | } |
| | | } |
| | | |
| | | export function getBatchTraceDetail(code: string) { |
| | | return requestClient.get<MesTraceBatchApi.Detail>('/mes/trace/batch/detail', { |
| | | params: { code }, |
| | | }); |
| | | } |
| | |
| | | warningLevel?: 'LOW_STOCK' | 'EXPIRING' | 'AGEING' | null; // é£é©ç±»å |
| | | receiptTime?: string; // å
¥åºæ¥æ |
| | | frozen?: boolean; // æ¯å¦å»ç» |
| | | bagCount?: number; // å½åè¢ç æ°é |
| | | availableBagCount?: number; // å¯ç¨è¢ç æ°é |
| | | bagDetailSupported?: boolean; // æ¯å¦åå¨è¢ç æç» |
| | | createTime?: Date; // å建æ¶é´ |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | /** åºåå°è´¦å页æ¥è¯¢åæ° */ |
| | | export interface MaterialStockPageParams extends PageParam { |
| | | warehouseType?: number; // ä»åºç±»åï¼10æååºï¼20åæ ¼åºï¼æç±»åè¿æ»¤ä»åºï¼ |
| | | stockState?: number; // åºåç¶æï¼10æååºï¼20åæ ¼åºï¼30å·²åºåº |
| | | } |
| | | |
| | | /** æ¥è¯¢åºåå°è´¦å页 */ |
| | | export function getMaterialStockPage(params: PageParam) { |
| | | export function getMaterialStockPage(params: MaterialStockPageParams) { |
| | | return requestClient.get<PageResult<MesWmMaterialStockApi.MaterialStock>>( |
| | | '/mes/wm/material-stock/page', |
| | | { params }, |
| | |
| | | } |
| | | |
| | | /** æ¥è¯¢åºåå°è´¦è¯¦æ
*/ |
| | | export interface MaterialStockBagDetail { |
| | | id: number; |
| | | materialStockId: number; |
| | | bagCodeId?: number; |
| | | bagCode: string; |
| | | batchId?: number; |
| | | batchCode?: string; |
| | | warehouseId?: number; |
| | | locationId?: number; |
| | | areaId?: number; |
| | | stockState?: number; |
| | | status?: number; |
| | | quantity?: number; |
| | | frozen?: boolean; |
| | | inboundTime?: string; |
| | | outboundTime?: string; |
| | | } |
| | | |
| | | export function getMaterialStockBagDetails(params: { |
| | | bagCode?: string; |
| | | materialStockId: number; |
| | | pageNo?: number; |
| | | pageSize?: number; |
| | | }) { |
| | | return requestClient.get<PageResult<MaterialStockBagDetail>>( |
| | | '/mes/wm/material-stock/bag-details', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | export function getMaterialStock(id: number) { |
| | | return requestClient.get<MesWmMaterialStockApi.MaterialStock>( |
| | | `/mes/wm/material-stock/get?id=${id}`, |
| | |
| | | /** åºåæ±æ»æ¥è¯¢åæ° */ |
| | | export interface MaterialStockSummaryParams { |
| | | stockState?: number; // åºåç¶æï¼10æååºï¼20åæ ¼åºï¼30å·²åºåº |
| | | warehouseType?: number; // ä»åºç±»åï¼10æååºï¼20åæ ¼åºï¼æç±»åè¿æ»¤ä»åºï¼ |
| | | itemId?: number; // åç§ID |
| | | batchCode?: string; // æ¹æ¬¡å· |
| | | warehouseId?: number; // ä»åºID |
| | |
| | | area?: number; // é¢ç§¯ |
| | | chargeUserId?: number; // è´è´£äºº |
| | | frozen?: boolean; // æ¯å¦å»ç» |
| | | type?: number; // ä»åºç±»åï¼10æååºï¼20åæ ¼åº |
| | | remark?: string; // 夿³¨ |
| | | createTime?: Date; // å建æ¶é´ |
| | | } |
| | |
| | | OUT: 30, // å·²åºåº |
| | | } as const; |
| | | |
| | | /** MES ä»åºç±»åæä¸¾ï¼å¯¹é½å端 MesWmWarehouseTypeEnumï¼ */ |
| | | export const MesWmWarehouseTypeEnum = { |
| | | TEMP: 10, // æååºï¼å¦èæçº¿è¾¹ä»åºï¼åæ¾æ«ç å
¥åºå¾
æ£/æååºåï¼ |
| | | QUALIFIED: 20, // åæ ¼åºï¼åæ¾è´¨æ£åæ ¼ã坿£è´§åºåï¼ |
| | | } as const; |
| | | |
| | | /** MES åºåé¢è¦ç±»åæä¸¾ */ |
| | | export const MesWmStockWarningTypeEnum = { |
| | | LOW_STOCK: 10, // ä½åºå |
| | |
| | | EffectScatterChart, |
| | | FunnelChart, |
| | | GaugeChart, |
| | | GraphChart, |
| | | LineChart, |
| | | LinesChart, |
| | | MapChart, |
| | |
| | | LineChart, |
| | | FunnelChart, |
| | | GaugeChart, |
| | | GraphChart, |
| | | ScatterChart, |
| | | EffectScatterChart, |
| | | LinesChart, |
| | |
| | | import type { EChartsOption } from '@vben/plugins/echarts'; |
| | | |
| | | /** åä¸ä¸»é¢ï¼ç¿ 绿主è²ï¼å¯¹é½ç»å½é¡µï¼æµ
è²ç»ççï¼ */ |
| | | export const AGRI_GREEN = '#10b981'; |
| | | export const AGRI_GREEN_LIGHT = '#34d399'; |
| | | export const AGRI_GREEN_PALE = '#6ee7b7'; |
| | | export const AGRI_TEXT = '#134e4a'; |
| | | export const AGRI_TEXT_MUTED = 'rgba(15, 78, 60, 0.62)'; |
| | | export const AGRI_SPLIT = 'rgba(16, 185, 129, 0.18)'; |
| | | |
| | | /** æ·±åºéç¨ææ¬æ ·å¼ */ |
| | | const darkText: NonNullable<EChartsOption['textStyle']> = { color: AGRI_TEXT }; |
| | | |
| | | /** è¢ç çæè¶å¿æ±ç¶å¾é
ç½® */ |
| | | export function getBagCodeTrendChartOptions( |
| | | dates: string[], |
| | |
| | | ): EChartsOption { |
| | | return { |
| | | grid: { bottom: 40, left: 50, right: 20, top: 20 }, |
| | | legend: { bottom: 0, data: ['è¢ç çæé'] }, |
| | | legend: { bottom: 0, data: ['è¢ç çæé'], textStyle: { color: AGRI_TEXT_MUTED } }, |
| | | series: [ |
| | | { |
| | | barMaxWidth: 32, |
| | | data: counts, |
| | | itemStyle: { borderRadius: 4, color: '#409EFF' }, |
| | | itemStyle: { borderRadius: 4, color: AGRI_GREEN }, |
| | | name: 'è¢ç çæé', |
| | | type: 'bar', |
| | | }, |
| | | ], |
| | | textStyle: darkText, |
| | | tooltip: { axisPointer: { type: 'shadow' }, trigger: 'axis' }, |
| | | xAxis: { axisTick: { show: false }, data: dates, type: 'category' }, |
| | | yAxis: { minInterval: 1, type: 'value' }, |
| | | xAxis: { |
| | | axisLine: { lineStyle: { color: AGRI_SPLIT } }, |
| | | axisLabel: { color: AGRI_TEXT_MUTED }, |
| | | axisTick: { show: false }, |
| | | data: dates, |
| | | type: 'category', |
| | | }, |
| | | yAxis: { |
| | | minInterval: 1, |
| | | splitLine: { lineStyle: { color: AGRI_SPLIT } }, |
| | | axisLabel: { color: AGRI_TEXT_MUTED }, |
| | | type: 'value', |
| | | }, |
| | | }; |
| | | } |
| | | |
| | |
| | | data: Array<{ itemStyle: { color: string }; name: string; value: number }>, |
| | | ): EChartsOption { |
| | | return { |
| | | legend: { bottom: 0, type: 'scroll' }, |
| | | legend: { bottom: 0, textStyle: { color: AGRI_TEXT_MUTED }, type: 'scroll' }, |
| | | series: [ |
| | | { |
| | | avoidLabelOverlap: true, |
| | | data, |
| | | emphasis: { label: { fontSize: 14, fontWeight: 'bold', show: true } }, |
| | | itemStyle: { borderColor: '#fff', borderRadius: 6, borderWidth: 2 }, |
| | | label: { formatter: '{b}\n{c}', show: true }, |
| | | itemStyle: { borderColor: '#ffffff', borderRadius: 6, borderWidth: 2 }, |
| | | label: { color: AGRI_TEXT, formatter: '{b}\n{c}', show: true }, |
| | | radius: ['40%', '70%'], |
| | | type: 'pie', |
| | | }, |
| | | ], |
| | | textStyle: darkText, |
| | | tooltip: { formatter: '{b}: {c} ({d}%)', trigger: 'item' }, |
| | | }; |
| | | } |
| | | |
| | | /** æ¶è´¹è
æ«ç å°åºå叿¨ªåæ±ç¶å¾é
ç½® */ |
| | | export function getRegionBarChartOptions( |
| | | data: Array<{ name: string; count: number }>, |
| | | ): EChartsOption { |
| | | const sorted = [...data].sort((a, b) => b.count - a.count); |
| | | return { |
| | | grid: { bottom: 20, left: 16, right: 40, top: 20, containLabel: true }, |
| | | series: [ |
| | | { |
| | | barMaxWidth: 20, |
| | | data: sorted.map((d) => d.count), |
| | | itemStyle: { borderRadius: 4, color: AGRI_GREEN }, |
| | | name: 'æ«ç 次æ°', |
| | | type: 'bar', |
| | | }, |
| | | ], |
| | | textStyle: darkText, |
| | | tooltip: { axisPointer: { type: 'shadow' }, trigger: 'axis' }, |
| | | xAxis: { |
| | | axisLabel: { color: AGRI_TEXT_MUTED }, |
| | | axisLine: { lineStyle: { color: AGRI_SPLIT } }, |
| | | splitLine: { lineStyle: { color: AGRI_SPLIT } }, |
| | | type: 'value', |
| | | }, |
| | | yAxis: { |
| | | axisLabel: { color: AGRI_TEXT }, |
| | | axisLine: { lineStyle: { color: AGRI_SPLIT } }, |
| | | data: sorted.map((d) => d.name), |
| | | type: 'category', |
| | | inverse: true, |
| | | }, |
| | | }; |
| | | } |
| | |
| | | lineCount: 0, |
| | | mdmItemCount: 0, |
| | | mesItemCount: 0, |
| | | ncrPendingCount: 0, |
| | | palletCount: 0, |
| | | productSalesCount: 0, |
| | | returnSalesCount: 0, |
| | | salesNoticeCount: 0, |
| | | stockWarningPendingCount: 0, |
| | | unitMeasureCount: 0, |
| | | }; |
| | | |
| | | /** æ¹æ¬¡ç¶æå¯¹åºçé¢è²æ å°ï¼ç¶æåå
¸ï¼10 è稿 20 çäº§ä¸ 30 已宿 40 å·²ä½åºï¼ */ |
| | | /** æ¹æ¬¡ç¶æå¯¹åºçé¢è²æ å°ï¼ç¶æåå
¸ï¼10 è稿 20 çäº§ä¸ 30 已宿 40 å·²ä½åºï¼å¯¹é½åä¸ç¿ ç»¿ä¸»é¢ */ |
| | | export const BATCH_STATUS_COLOR_MAP: Record<number, string> = { |
| | | 10: '#909399', // è稿 |
| | | 20: '#409EFF', // çäº§ä¸ |
| | | 30: '#67C23A', // 已宿 |
| | | 40: '#F56C6C', // å·²ä½åº |
| | | 10: '#94a3b8', // è稿 |
| | | 20: '#10b981', // çäº§ä¸ |
| | | 30: '#34d399', // 已宿 |
| | | 40: '#fca5a5', // å·²ä½åº |
| | | }; |
| | | |
| | | /** è¢ç ç¶æå¯¹åºçé¢è²æ å°ï¼ç¶æåå
¸ï¼10 å·²çæ 20 已导åºå°å· 30 å·²ä½åºï¼ */ |
| | | export const BAG_CODE_STATUS_COLOR_MAP: Record<number, string> = { |
| | | 10: '#409EFF', // å·²çæ |
| | | 20: '#E6A23C', // 已导åºå°å· |
| | | 30: '#F56C6C', // å·²ä½åº |
| | | 10: '#10b981', // å·²çæ |
| | | 20: '#e6a23c', // 已导åºå°å· |
| | | 30: '#fca5a5', // å·²ä½åº |
| | | }; |
| | |
| | | <script lang="ts" setup> |
| | | import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home'; |
| | | import type { MesTraceCrossRegionApi } from '#/api/mes/trace-cross-region'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | |
| | | import { Col, Row, message } from 'ant-design-vue'; |
| | | |
| | | import { getBagCodeHomeSummary } from '#/api/mes/bag-code-home'; |
| | | import { getTraceCrossRegionSummary } from '#/api/mes/trace-cross-region'; |
| | | import { getTraceConsumerScanSummary } from '#/api/mes/trace-consumer-scan'; |
| | | |
| | | import { defaultSummary } from './data'; |
| | | import BagCodeCards from './modules/bag-code-cards.vue'; |
| | | import AlertPanel from './modules/alert-panel.vue'; |
| | | import BagCodeStatusChart from './modules/bag-code-status-chart.vue'; |
| | | import BasicCards from './modules/basic-cards.vue'; |
| | | import BatchStatusChart from './modules/batch-status-chart.vue'; |
| | | import BusinessFlow from './modules/business-flow.vue'; |
| | | import KpiCards from './modules/kpi-cards.vue'; |
| | | import ScanRegionChart from './modules/scan-region-chart.vue'; |
| | | import Shortcuts from './modules/shortcuts.vue'; |
| | | import TrendChart from './modules/trend-chart.vue'; |
| | | |
| | |
| | | |
| | | const router = useRouter(); |
| | | const summary = ref<MesBagCodeHomeApi.Summary>(defaultSummary); // é¦é¡µæ±æ»ç»è®¡ |
| | | const crossRegion = ref<MesTraceCrossRegionApi.Summary>({}); // çªè´§é¢è¦æ±æ» |
| | | const scanTotal = ref(0); // 累计æ«ç æ¬¡æ° |
| | | |
| | | /** 跳转å°ç®æ 页é¢ï¼æè·¯ç± nameï¼æªå è½½æ¶æç¤ºï¼ */ |
| | | async function handleNavigate(name: string) { |
| | |
| | | } |
| | | } |
| | | |
| | | /** å è½½é¦é¡µæ±æ»ç»è®¡ */ |
| | | /** å è½½é¦é¡µæ±æ»ç»è®¡ãçªè´§é¢è¦æ±æ»ãæ¶è´¹è
æ«ç æ±æ» */ |
| | | async function loadSummary() { |
| | | summary.value = await getBagCodeHomeSummary(); |
| | | crossRegion.value = await getTraceCrossRegionSummary(); |
| | | const scan = await getTraceConsumerScanSummary(); |
| | | scanTotal.value = scan.total ?? 0; |
| | | } |
| | | |
| | | onMounted(() => { |
| | |
| | | |
| | | <template> |
| | | <Page> |
| | | <!-- 第ä¸è¡ï¼åºç¡æ°æ®å¡ç --> |
| | | <BasicCards :summary="summary" class="mb-2" @navigate="handleNavigate" /> |
| | | <div class="agri-dashboard"> |
| | | <!-- 第ä¸è¡ï¼ä¸å¡å
¨é¾è·¯ --> |
| | | <BusinessFlow |
| | | :cross-region-total="crossRegion.total ?? 0" |
| | | :scan-total="scanTotal" |
| | | :summary="summary" |
| | | @navigate="handleNavigate" |
| | | /> |
| | | |
| | | <!-- 第äºè¡ï¼ä¸è¢ä¸ç å¡ç --> |
| | | <BagCodeCards :summary="summary" class="mb-4" @navigate="handleNavigate" /> |
| | | <!-- 第äºè¡ï¼å
¨é¾è·¯ KPI æ»è§ --> |
| | | <KpiCards :scan-total="scanTotal" :summary="summary" @navigate="handleNavigate" /> |
| | | |
| | | <!-- 第ä¸è¡ï¼è¢ç è¶å¿ + æ¹æ¬¡/è¢ç ç¶æåå¸ --> |
| | | <Row :gutter="16" class="mb-4"> |
| | | <Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4"> |
| | | <TrendChart /> |
| | | </Col> |
| | | <Col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" class="mb-4"> |
| | | <BatchStatusChart :list="summary.batchStatusList" /> |
| | | </Col> |
| | | <Col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" class="mb-4"> |
| | | <BagCodeStatusChart :list="summary.bagCodeStatusList" /> |
| | | </Col> |
| | | </Row> |
| | | <!-- 第ä¸è¡ï¼è¢ç çæè¶å¿ + æ¶è´¹è
æ«ç å°åºåå¸ --> |
| | | <Row :gutter="16" class="mb-4"> |
| | | <Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4"> |
| | | <TrendChart /> |
| | | </Col> |
| | | <Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4"> |
| | | <ScanRegionChart /> |
| | | </Col> |
| | | </Row> |
| | | |
| | | <!-- 第åè¡ï¼å¿«æ·å
¥å£ --> |
| | | <Shortcuts @navigate="handleNavigate" /> |
| | | <!-- 第åè¡ï¼æ¹æ¬¡/è¢ç ç¶æåå¸ + å¾
åä¸é¢è¦ --> |
| | | <Row :gutter="16" class="mb-4"> |
| | | <Col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" class="mb-4"> |
| | | <BatchStatusChart :list="summary.batchStatusList" /> |
| | | </Col> |
| | | <Col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" class="mb-4"> |
| | | <BagCodeStatusChart :list="summary.bagCodeStatusList" /> |
| | | </Col> |
| | | <Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24" class="mb-4"> |
| | | <AlertPanel :cross-region="crossRegion" :summary="summary" @navigate="handleNavigate" /> |
| | | </Col> |
| | | </Row> |
| | | |
| | | <!-- 第äºè¡ï¼æä¸å¡ååç»çå¿«æ·å
¥å£ --> |
| | | <Shortcuts @navigate="handleNavigate" /> |
| | | </div> |
| | | </Page> |
| | | </template> |
| | | |
| | | <style lang="scss"> |
| | | /* åä¸ç¿ 绿ç»çææé¦é¡µä¸»é¢ ââ 对é½ç»å½é¡µ `agri-auth` è²è°ï¼éç¨æ´æ¸
æ·¡çæµ
è²ç»çä¸é«çº§æ¸å */ |
| | | .agri-dashboard { |
| | | position: relative; |
| | | padding: 1rem; |
| | | border-radius: 1.25rem; |
| | | background: |
| | | radial-gradient(900px 480px at 8% 0%, rgba(110, 231, 183, 0.42), transparent 55%), |
| | | radial-gradient(800px 420px at 96% 100%, rgba(6, 95, 70, 0.12), transparent 60%), |
| | | linear-gradient(160deg, #f0fdf9 0%, #dcf7ea 50%, #c7f0d9 100%); |
| | | isolation: isolate; |
| | | color: #0f5c49; |
| | | |
| | | /* é«çº§ç½ç»çå¡çï¼å
æè¾¹ + 磨ç + æå屿¬¡é´å½± */ |
| | | .ant-card { |
| | | color: #135746; |
| | | background: linear-gradient(160deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.55)); |
| | | border: 1px solid rgba(16, 185, 129, 0.18); |
| | | box-shadow: |
| | | inset 0 1px 0 rgba(255, 255, 255, 0.8), |
| | | 0 12px 30px rgba(6, 78, 59, 0.08); |
| | | backdrop-filter: blur(14px); |
| | | } |
| | | |
| | | .ant-card-head { |
| | | border-bottom-color: rgba(16, 185, 129, 0.14); |
| | | } |
| | | |
| | | .ant-card-head-title, |
| | | .ant-card-head-title .anticon { |
| | | color: #0f766e; |
| | | } |
| | | |
| | | .ant-card:hover { |
| | | box-shadow: |
| | | inset 0 1px 0 rgba(255, 255, 255, 0.9), |
| | | 0 18px 44px rgba(6, 78, 59, 0.14); |
| | | border-color: rgba(16, 185, 129, 0.34); |
| | | } |
| | | |
| | | /* è¦ç App æµ
è²ä¸»é¢çè¯ä¹è²ï¼ç»ä¸ä¸ºæå墨绿 */ |
| | | .text-muted-foreground { |
| | | color: rgba(15, 78, 60, 0.62); |
| | | } |
| | | |
| | | .text-2xl.font-bold { |
| | | color: #047857; |
| | | } |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home'; |
| | | import type { MesTraceCrossRegionApi } from '#/api/mes/trace-cross-region'; |
| | | |
| | | import { computed } from 'vue'; |
| | | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { Badge, Card } from 'ant-design-vue'; |
| | | |
| | | defineOptions({ name: 'BagCodeHomeAlertPanel' }); |
| | | |
| | | const props = defineProps<{ |
| | | summary: MesBagCodeHomeApi.Summary; |
| | | crossRegion: MesTraceCrossRegionApi.Summary; |
| | | }>(); |
| | | |
| | | const emit = defineEmits<{ |
| | | navigate: [name: string]; |
| | | }>(); |
| | | |
| | | /** å¾
åä¸é¢è¦å表 */ |
| | | const alertItems = computed(() => [ |
| | | { |
| | | count: props.crossRegion.pending ?? 0, |
| | | desc: 'å¾
å¤çççªè´§é¢è¦', |
| | | icon: 'lucide:shield-alert', |
| | | iconClass: 'bg-[rgba(239,68,68,0.12)] text-[#ef4444]', |
| | | label: 'çªè´§é¢è¦', |
| | | routeName: 'MesTraceCrossRegion', |
| | | }, |
| | | { |
| | | count: props.summary.stockWarningPendingCount ?? 0, |
| | | desc: 'æªå¤ççåºåé¢è¦', |
| | | icon: 'lucide:bell-alert', |
| | | iconClass: 'bg-[rgba(245,158,11,0.14)] text-[#f59e0b]', |
| | | label: 'åºåé¢è¦', |
| | | routeName: 'MesWmStockWarning', |
| | | }, |
| | | { |
| | | count: props.summary.ncrPendingCount ?? 0, |
| | | desc: 'å¾
è¯å®¡çä¸åæ ¼åå', |
| | | icon: 'lucide:shield-x', |
| | | iconClass: 'bg-[rgba(16,185,129,0.14)] text-[#10b981]', |
| | | label: 'ä¸åæ ¼å', |
| | | routeName: 'MesQcNcr', |
| | | }, |
| | | ]); |
| | | </script> |
| | | |
| | | <template> |
| | | <Card title="å¾
åä¸é¢è¦" class="h-full"> |
| | | <div class="flex flex-col"> |
| | | <div |
| | | v-for="item in alertItems" |
| | | :key="item.label" |
| | | class="flex cursor-pointer items-center gap-3 border-b border-[rgba(16,185,129,0.16)] px-2 py-4 transition-colors last:border-b-0 hover:bg-[rgba(16,185,129,0.08)]" |
| | | @click="emit('navigate', item.routeName)" |
| | | > |
| | | <div |
| | | class="flex size-10 flex-shrink-0 items-center justify-center rounded-lg" |
| | | :class="item.iconClass" |
| | | > |
| | | <IconifyIcon class="size-5" :icon="item.icon" /> |
| | | </div> |
| | | <div class="flex flex-1 flex-col gap-0.5"> |
| | | <span class="text-sm font-medium">{{ item.label }}</span> |
| | | <span class="text-muted-foreground text-xs">{{ item.desc }}</span> |
| | | </div> |
| | | <Badge :count="item.count" :show-zero="false" /> |
| | | </div> |
| | | </div> |
| | | </Card> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home'; |
| | | |
| | | import { computed } from 'vue'; |
| | | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { Card } from 'ant-design-vue'; |
| | | |
| | | defineOptions({ name: 'BagCodeHomeBusinessFlow' }); |
| | | |
| | | const props = defineProps<{ |
| | | summary: MesBagCodeHomeApi.Summary; |
| | | scanTotal: number; // 累计æ«ç æ¬¡æ° |
| | | crossRegionTotal: number; // çªè´§é¢è¦æ»æ° |
| | | }>(); |
| | | |
| | | const emit = defineEmits<{ |
| | | navigate: [name: string]; |
| | | }>(); |
| | | |
| | | /** æ°å¼å
åºï¼åæ®µç¼ºå¤±æ¶æ 0 å¤çï¼é¿å
toLocaleString æ¥é */ |
| | | const num = (v?: number) => v ?? 0; |
| | | |
| | | /** å
¨é¾è·¯ä¸å¡èç¹ï¼åç§ â 产线 â æ¹æ¬¡ â è¢ç â æç â åºåº â æµéæ«ç â é²çªæº¯æºï¼ç¿ 绿主é¢ï¼æµé/é²çªä¿çè¯ä¹è²ï¼ */ |
| | | const nodes = computed(() => [ |
| | | { color: '#10b981', count: num(props.summary.mdmItemCount), icon: 'lucide:package', label: 'åç§', routeName: 'MdmItem' }, |
| | | { color: '#34d399', count: num(props.summary.lineCount), icon: 'lucide:factory', label: '产线', routeName: 'MesProLine' }, |
| | | { color: '#0d9488', count: num(props.summary.batchCount), icon: 'lucide:layers', label: 'çäº§æ¹æ¬¡', routeName: 'MesProBatch' }, |
| | | { color: '#059669', count: num(props.summary.bagCodeCount), icon: 'lucide:scan-barcode', label: 'è¢ç ', routeName: 'MesProBagCode' }, |
| | | { color: '#2dd4bf', count: num(props.summary.palletCount), icon: 'lucide:grid-2x2', label: 'æçç ', routeName: 'MesProPallet' }, |
| | | { color: '#0f766e', count: num(props.summary.productSalesCount), icon: 'lucide:truck', label: 'éå®åºåº', routeName: 'MesWmProductSales' }, |
| | | { color: '#f59e0b', count: num(props.scanTotal), icon: 'lucide:scan-line', label: 'æµéæ«ç ', routeName: 'MesConsumerScanStatistics' }, |
| | | { color: '#f43f5e', count: num(props.crossRegionTotal), icon: 'lucide:shield-alert', label: 'é²çª/溯æº', routeName: 'MesTraceCrossRegion' }, |
| | | ]); |
| | | </script> |
| | | |
| | | <template> |
| | | <Card class="mb-4"> |
| | | <template #title> |
| | | <span class="flex items-center gap-2"> |
| | | <IconifyIcon class="size-5" icon="lucide:waypoints" /> |
| | | ä¸å¡å
¨é¾è·¯ |
| | | </span> |
| | | </template> |
| | | <!-- ä»å·¦å°å³ä¸²èæ¯ä¸ªä¸å¡èç¹ï¼ä¸é´ç¨ç®å¤´è¡æ¥ --> |
| | | <div class="flex flex-col gap-3 lg:flex-row lg:items-stretch"> |
| | | <template v-for="(node, index) in nodes" :key="node.label"> |
| | | <div |
| | | class="flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-xl border border-[rgba(16,185,129,0.22)] bg-[rgba(255,255,255,0.68)] p-3 backdrop-blur transition-all hover:-translate-y-0.5 hover:bg-[rgba(16,185,129,0.12)] hover:shadow-md" |
| | | @click="emit('navigate', node.routeName)" |
| | | > |
| | | <div |
| | | class="flex size-11 flex-shrink-0 items-center justify-center rounded-xl text-white" |
| | | :style="{ background: node.color }" |
| | | > |
| | | <IconifyIcon class="size-6" :icon="node.icon" /> |
| | | </div> |
| | | <div class="flex min-w-0 flex-col gap-0.5"> |
| | | <span class="text-muted-foreground truncate text-xs">{{ node.label }}</span> |
| | | <span class="text-lg font-bold leading-tight">{{ node.count.toLocaleString() }}</span> |
| | | </div> |
| | | </div> |
| | | <!-- èç¹é´ç®å¤´ï¼å°å±éèï¼ --> |
| | | <div |
| | | v-if="index < nodes.length - 1" |
| | | class="hidden items-center lg:flex" |
| | | > |
| | | <IconifyIcon class="text-muted-foreground size-4" icon="lucide:chevron-right" /> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | </Card> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { MesBagCodeHomeApi } from '#/api/mes/bag-code-home'; |
| | | |
| | | import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; |
| | | |
| | | import { gsap } from 'gsap'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { Card, Col, Row } from 'ant-design-vue'; |
| | | |
| | | defineOptions({ name: 'BagCodeHomeKpiCards' }); |
| | | |
| | | const props = defineProps<{ |
| | | summary: MesBagCodeHomeApi.Summary; |
| | | scanTotal: number; // 累计æ«ç æ¬¡æ° |
| | | }>(); |
| | | |
| | | const emit = defineEmits<{ |
| | | navigate: [name: string]; |
| | | }>(); |
| | | |
| | | /** å
¨é¾è·¯ KPI å¡çï¼è¦çåºç¡æ°æ®/ç产/ä¸è¢ä¸ç /åºåº/æµé/ä»å¨/è´¨éï¼ç¿ 绿主é¢ï¼é¢è¦/ä¸åæ ¼ä¿çè¯ä¹è²ï¼ */ |
| | | const cards = computed(() => [ |
| | | { gradient: 'linear-gradient(135deg, #10b981, #047857)', icon: 'lucide:package', name: 'åç§æ»æ°', routeName: 'MdmItem', sub: 'åºç¡æ°æ®', value: () => props.summary.mdmItemCount ?? 0 }, |
| | | { gradient: 'linear-gradient(135deg, #34d399, #059669)', icon: 'lucide:layers', name: 'çäº§æ¹æ¬¡', routeName: 'MesProBatch', sub: 'ä¸è¢ä¸ç ', value: () => props.summary.batchCount ?? 0 }, |
| | | { gradient: 'linear-gradient(135deg, #059669, #047857)', icon: 'lucide:scan-barcode', name: 'è¢ç ', routeName: 'MesProBagCode', sub: 'èµç ', value: () => props.summary.bagCodeCount ?? 0 }, |
| | | { gradient: 'linear-gradient(135deg, #6ee7b7, #10b981)', icon: 'lucide:grid-2x2', name: 'æçç ', routeName: 'MesProPallet', sub: 'æç', value: () => props.summary.palletCount ?? 0 }, |
| | | { gradient: 'linear-gradient(135deg, #0d9488, #0f766e)', icon: 'lucide:truck', name: 'éå®åºåºå', routeName: 'MesWmProductSales', sub: 'åºåºæµé', value: () => props.summary.productSalesCount ?? 0 }, |
| | | { gradient: 'linear-gradient(135deg, #10b981, #0d9488)', icon: 'lucide:scan-line', name: '累计æ«ç ', routeName: 'MesConsumerScanStatistics', sub: '溯æº', value: () => props.scanTotal ?? 0 }, |
| | | { gradient: 'linear-gradient(135deg, #f59e0b, #b45309)', icon: 'lucide:bell-alert', name: 'åºåé¢è¦', routeName: 'MesWmStockWarning', sub: 'ä»å¨', value: () => props.summary.stockWarningPendingCount ?? 0 }, |
| | | { gradient: 'linear-gradient(135deg, #f43f5e, #e11d48)', icon: 'lucide:shield-x', name: 'å¾
è¯å®¡ä¸åæ ¼', routeName: 'MesQcNcr', sub: 'è´¨é', value: () => props.summary.ncrPendingCount ?? 0 }, |
| | | ]); |
| | | |
| | | /** æ¯ä¸ªå¡çç®æ æ°åï¼display 为 GSAP 计æ°å¨ç»è¿ç¨ä¸å±ç¤ºçææ¬ */ |
| | | const targets = computed(() => cards.value.map((card) => card.value())); |
| | | const display = ref<string[]>(cards.value.map(() => '0')); |
| | | |
| | | let tweens: gsap.core.Tween[] = []; |
| | | |
| | | /** ç¨ GSAP ææ°åä» 0 æ»å¨å°ç®æ å¼ï¼åå使 ¼å¼åï¼ */ |
| | | function animateTo(to: number[]) { |
| | | tweens.forEach((t) => t.kill()); |
| | | tweens = []; |
| | | display.value = to.map(() => '0'); |
| | | to.forEach((endVal, index) => { |
| | | const proxy = { v: 0 }; |
| | | tweens.push( |
| | | gsap.to(proxy, { |
| | | duration: 1.2, |
| | | ease: 'power2.out', |
| | | onComplete: () => { |
| | | display.value[index] = endVal.toLocaleString(); |
| | | }, |
| | | onUpdate: () => { |
| | | display.value[index] = Math.round(proxy.v).toLocaleString(); |
| | | }, |
| | | v: endVal, |
| | | }), |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | onMounted(() => { |
| | | animateTo(targets.value); |
| | | }); |
| | | |
| | | // æ±æ»æ°æ®å è½½åï¼æ°åç± 0 æ»å¨å°çå®å¼ |
| | | watch(targets, (val) => animateTo(val)); |
| | | |
| | | onUnmounted(() => { |
| | | tweens.forEach((t) => t.kill()); |
| | | tweens = []; |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Row :gutter="16"> |
| | | <Col |
| | | v-for="(card, index) in cards" |
| | | :key="card.name" |
| | | :lg="6" |
| | | :md="12" |
| | | :sm="12" |
| | | :xl="6" |
| | | :xs="24" |
| | | class="mb-4" |
| | | > |
| | | <Card |
| | | class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg" |
| | | @click="emit('navigate', card.routeName)" |
| | | > |
| | | <div class="flex items-center gap-4"> |
| | | <div |
| | | class="flex size-14 flex-shrink-0 items-center justify-center rounded-xl text-white" |
| | | :style="{ background: card.gradient }" |
| | | > |
| | | <IconifyIcon class="size-7" :icon="card.icon" /> |
| | | </div> |
| | | <div class="min-w-0"> |
| | | <div class="text-muted-foreground mb-1 text-sm">{{ card.name }}</div> |
| | | <div class="text-2xl font-bold leading-tight">{{ display[index] }}</div> |
| | | <div class="text-muted-foreground mt-1 truncate text-xs">{{ card.sub }}</div> |
| | | </div> |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | </Row> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { EchartsUIType } from '@vben/plugins/echarts'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | |
| | | import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
| | | |
| | | import { Card } from 'ant-design-vue'; |
| | | |
| | | import { getTraceConsumerScanDistribution } from '#/api/mes/trace-consumer-scan'; |
| | | |
| | | import { getRegionBarChartOptions } from '../chart-options'; |
| | | |
| | | defineOptions({ name: 'BagCodeHomeScanRegionChart' }); |
| | | |
| | | const chartRef = ref<EchartsUIType>(); |
| | | const { renderEcharts } = useEcharts(chartRef); |
| | | |
| | | /** å è½½æ¶è´¹è
æ«ç å°åºï¼ç份ï¼åå¸ */ |
| | | async function loadData() { |
| | | const data = await getTraceConsumerScanDistribution('province'); |
| | | // å Top 10 ç份ï¼é¿å
横åè¿é¿ |
| | | const top = data.slice(0, 10).map((d) => ({ count: d.count, name: d.name })); |
| | | await renderEcharts(getRegionBarChartOptions(top)); |
| | | } |
| | | |
| | | onMounted(() => { |
| | | loadData(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Card title="æ¶è´¹è
æ«ç å°åºåå¸" class="h-full"> |
| | | <EchartsUI ref="chartRef" class="h-[320px] w-full" /> |
| | | </Card> |
| | | </template> |
| | |
| | | |
| | | const router = useRouter(); |
| | | |
| | | /** å¿«æ·å
¥å£åè¡¨ï¼æä¸å¡åç²¾ç®ä¸º 4 ä¸ªæ ¸å¿å
¥å£ */ |
| | | const shortcuts = [ |
| | | { bgColor: '#409EFF', icon: 'lucide:package', name: 'åç§ç®¡ç', routeName: 'MdmItem' }, |
| | | { bgColor: '#E6A23C', icon: 'lucide:folder-tree', name: 'åç§åç±»', routeName: 'MesMdItemType' }, |
| | | { bgColor: '#f59e0b', icon: 'lucide:layers', name: 'æ¹æ¬¡ç产', routeName: 'MesProBatch' }, |
| | | { bgColor: '#ec4899', icon: 'lucide:scan-barcode', name: 'èµç 管ç', routeName: 'MesProBagCode' }, |
| | | interface Shortcut { |
| | | icon: string; |
| | | name: string; |
| | | routeName: string; |
| | | } |
| | | |
| | | interface ShortcutGroup { |
| | | title: string; |
| | | items: Shortcut[]; |
| | | } |
| | | |
| | | /** æä¸å¡ååç»çå¿«æ·å
¥å£ */ |
| | | const groups: ShortcutGroup[] = [ |
| | | { |
| | | title: 'åºç¡æ°æ®', |
| | | items: [ |
| | | { icon: 'lucide:package', name: 'åç§ç®¡ç', routeName: 'MdmItem' }, |
| | | { icon: 'lucide:folder-tree', name: 'åç§åç±»', routeName: 'MesMdItemType' }, |
| | | { icon: 'lucide:ruler', name: '计éåä½', routeName: 'MdmUnit' }, |
| | | ], |
| | | }, |
| | | { |
| | | title: 'ä¸è¢ä¸ç ç产', |
| | | items: [ |
| | | { icon: 'lucide:factory', name: '产线维æ¤', routeName: 'MesProLine' }, |
| | | { icon: 'lucide:layers', name: 'æ¹æ¬¡ç产', routeName: 'MesProBatch' }, |
| | | { icon: 'lucide:scan-barcode', name: 'èµç 管ç', routeName: 'MesProBagCode' }, |
| | | { icon: 'lucide:grid-2x2', name: 'æçç ', routeName: 'MesProPallet' }, |
| | | { icon: 'lucide:scan-line', name: 'æ«ç å
¥åº', routeName: 'MesProScanIn' }, |
| | | ], |
| | | }, |
| | | { |
| | | title: 'åºåºæµé', |
| | | items: [ |
| | | { icon: 'lucide:send', name: 'åè´§éç¥', routeName: 'MesWmSalesNotice' }, |
| | | { icon: 'lucide:truck', name: 'éå®åºåº', routeName: 'MesWmProductSales' }, |
| | | { icon: 'lucide:rotate-ccw', name: 'éå®éè´§', routeName: 'MesWmReturnSales' }, |
| | | ], |
| | | }, |
| | | { |
| | | title: 'é²ä¼ªé²çªè´§', |
| | | items: [ |
| | | { icon: 'lucide:shield-alert', name: 'çªè´§é¢è¦', routeName: 'MesTraceCrossRegion' }, |
| | | { icon: 'lucide:map-pin', name: 'æ«ç å°çåå¸', routeName: 'MesConsumerScanDistribution' }, |
| | | { icon: 'lucide:database-check', name: '宿´æ§æ ¡éª', routeName: 'MesTraceIntegrity' }, |
| | | ], |
| | | }, |
| | | { |
| | | title: '溯æºåæ', |
| | | items: [{ icon: 'lucide:scan-line', name: 'æ¶è´¹è
æ«ç ç»è®¡', routeName: 'MesConsumerScanStatistics' }], |
| | | }, |
| | | { |
| | | title: 'ä»å¨ç©æµ', |
| | | items: [ |
| | | { icon: 'lucide:warehouse', name: 'ä»åºè®¾ç½®', routeName: 'MesWmWarehouse' }, |
| | | { icon: 'lucide:boxes', name: 'åºåç°æé', routeName: 'MesWmMaterialStock' }, |
| | | { icon: 'lucide:bell-alert', name: 'åºåé¢è¦', routeName: 'MesWmStockWarning' }, |
| | | { icon: 'lucide:move-right', name: 'è½¬ç§»è°æ¨', routeName: 'MesWmTransfer' }, |
| | | ], |
| | | }, |
| | | { |
| | | title: 'è´¨é管ç', |
| | | items: [ |
| | | { icon: 'lucide:list-tree', name: 'æ¹æ¬¡è¿½æº¯', routeName: 'BatchTrace' }, |
| | | { icon: 'lucide:shield-x', name: 'ä¸åæ ¼å', routeName: 'MesQcNcr' }, |
| | | { icon: 'lucide:bean', name: 'ç§åè´¨æ£', routeName: 'MesQcSeedQuality' }, |
| | | ], |
| | | }, |
| | | ]; |
| | | |
| | | /** 跳转å°ç®æ 页é¢ï¼æè·¯ç± nameï¼æªå è½½æ¶æç¤ºï¼ */ |
| | |
| | | <template> |
| | | <Card title="å¿«æ·å
¥å£" class="h-full"> |
| | | <Row :gutter="16"> |
| | | <Col v-for="item in shortcuts" :key="item.name" :lg="6" :md="8" :sm="12" :xs="12" class="mb-4"> |
| | | <div |
| | | class="hover:bg-accent flex cursor-pointer flex-col items-center gap-2 rounded-lg py-3 transition-all hover:-translate-y-0.5" |
| | | @click="handleNavigate(item.routeName)" |
| | | > |
| | | <div |
| | | class="flex size-12 items-center justify-center rounded-xl text-white" |
| | | :style="{ background: item.bgColor }" |
| | | > |
| | | <IconifyIcon class="size-6" :icon="item.icon" /> |
| | | <Col v-for="group in groups" :key="group.title" :lg="8" :md="12" :sm="24" :xs="24" class="mb-4"> |
| | | <div class="rounded-xl border border-[rgba(16,185,129,0.16)] bg-[rgba(255,255,255,0.55)] p-3 backdrop-blur"> |
| | | <div class="text-muted-foreground mb-2 text-xs font-medium">{{ group.title }}</div> |
| | | <div class="flex flex-wrap gap-2"> |
| | | <div |
| | | v-for="item in group.items" |
| | | :key="item.name" |
| | | class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-[rgba(16,185,129,0.22)] px-2 py-1.5 text-sm transition-colors hover:bg-[rgba(16,185,129,0.12)]" |
| | | @click="handleNavigate(item.routeName)" |
| | | > |
| | | <IconifyIcon class="size-4 text-[#0d9488]" :icon="item.icon" /> |
| | | <span>{{ item.name }}</span> |
| | | </div> |
| | | </div> |
| | | <span class="text-sm">{{ item.name }}</span> |
| | | </div> |
| | | </Col> |
| | | </Row> |
| | |
| | | getSaleOrderPage, |
| | | updateSaleOrderStatus, |
| | | } from '#/api/erp/sale/order'; |
| | | import { generateFromSaleOrder } from '#/api/mes/wm/salesnotice'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | import { useGridColumns, useGridFormSchema } from './data'; |
| | | import Form from './modules/form.vue'; |
| | | import SalesNoticeForm from '#/views/wls/salesnotice/modules/form.vue'; |
| | | |
| | | /** ERP éå®è®¢åå表 */ |
| | | defineOptions({ name: 'ErpSaleOrder' }); |
| | |
| | | |
| | | const [FormModal, formModalApi] = useVbenModal({ |
| | | connectedComponent: Form, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | const [SalesNoticeFormModal, salesNoticeFormModalApi] = useVbenModal({ |
| | | connectedComponent: SalesNoticeForm, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | /** çæåè´§éç¥å */ |
| | | async function handleGenerateNotice(row: ErpSaleOrderApi.SaleOrder) { |
| | | try { |
| | | const noticeId = await generateFromSaleOrder(row.id!); |
| | | message.success('çæåè´§éç¥åæå'); |
| | | handleRefresh(); |
| | | // ç´æ¥æå¼åè´§éç¥å详æ
å¼¹æ¡ |
| | | salesNoticeFormModalApi |
| | | .setData({ formType: 'detail', id: noticeId }) |
| | | .open(); |
| | | } catch (e) { |
| | | console.error(e); |
| | | } |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | formOptions: { |
| | | schema: useGridFormSchema(), |
| | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <FormModal @success="handleRefresh" /> |
| | | <SalesNoticeFormModal /> |
| | | <Grid table-title="éå®è®¢åå表"> |
| | | <template #toolbar-tools> |
| | | <TableAction |
| | |
| | | title: `确认å¯ç¨${row.no}åï¼`, |
| | | confirm: handleEnable.bind(null, row), |
| | | }, |
| | | }, |
| | | { |
| | | label: 'çæåè´§éç¥å', |
| | | type: 'link', |
| | | icon: 'ant-design:file-protect-outlined', |
| | | auth: ['mes:wm-sales-notice:create'], |
| | | ifShow: () => row.status === 20 && row.canCreateSalesNotice, |
| | | onClick: handleGenerateNotice.bind(null, row), |
| | | }, |
| | | { |
| | | label: $t('common.delete'), |
| | |
| | | import { gsap } from 'gsap'; |
| | | |
| | | import { |
| | | getLatestScanEvent, |
| | | getScanDashboard, |
| | | getScanEventPage, |
| | | inboundScanEvent, |
| | | type MesProScanEventApi, |
| | | } from '#/api/mes/pro/scanEvent'; |
| | | import { getLineSimpleList, type MesProLineApi } from '#/api/mes/pro/line'; |
| | | import { |
| | | getWarehouseLocationSimpleList, |
| | | type MesWmWarehouseLocationApi, |
| | | } from '#/api/mes/wm/warehouse/location'; |
| | | import { |
| | | getWarehouseAreaSimpleList, |
| | | type MesWmWarehouseAreaApi, |
| | | } from '#/api/mes/wm/warehouse/area'; |
| | | |
| | | interface ScanRecord extends MesProScanEventApi.ScanEvent { |
| | | key: number; |
| | | } |
| | | |
| | | const code = ref(''); |
| | | const deviceCode = ref(''); |
| | | const scanType = ref<MesProScanEventApi.ScanType>('BAG'); |
| | | const lineCode = ref(''); |
| | | const locationId = ref<number>(); |
| | | const areaId = ref<number>(); |
| | | const lines = ref<MesProLineApi.Line[]>([]); |
| | | const locations = ref<MesWmWarehouseLocationApi.WarehouseLocation[]>([]); |
| | | const areas = ref<MesWmWarehouseAreaApi.WarehouseArea[]>([]); |
| | | const loading = ref(false); |
| | | const refreshing = ref(false); |
| | | const records = ref<ScanRecord[]>([]); |
| | | const dashboard = ref<MesProScanEventApi.Dashboard>({}); |
| | | let timer: ReturnType<typeof setInterval> | undefined; |
| | | |
| | | /** æ«ç ç±»å䏿å */ |
| | | const SCAN_TYPE_LABEL: Record<MesProScanEventApi.ScanType, string> = { |
| | | BAG: 'è¢ç ', |
| | | PALLET: 'æçç ', |
| | | BATCH: 'æ¹æ¬¡ç ', |
| | | }; |
| | | /** æ«ç ç±»å䏿é项 */ |
| | | const SCAN_TYPE_OPTIONS = [ |
| | | { label: 'è¢ç ', value: 'BAG' }, |
| | | { label: 'æçç ', value: 'PALLET' }, |
| | | { label: 'æ¹æ¬¡ç ', value: 'BATCH' }, |
| | | ] as const; |
| | | |
| | | const latest = computed(() => dashboard.value.latest); |
| | | const successCount = computed(() => dashboard.value.success ?? records.value.filter((item) => item.status === 'SUCCESS').length); |
| | | const failedCount = computed(() => dashboard.value.failed ?? records.value.filter((item) => item.status === 'FAILED').length); |
| | | const isReady = computed(() => Boolean(deviceCode.value.trim() && locationId.value && areaId.value)); |
| | | const isReady = computed(() => Boolean(locationId.value && areaId.value)); |
| | | const lineOptions = computed(() => |
| | | lines.value.map((item) => ({ |
| | | label: item.code ? `${item.code} ${item.name ?? ''}`.trim() : (item.name ?? ''), |
| | | value: item.code ?? '', |
| | | })), |
| | | ); |
| | | const locationOptions = computed(() => |
| | | locations.value.map((item) => ({ |
| | | label: item.code ? `${item.code} ${item.name ?? ''}`.trim() : (item.name ?? ''), |
| | | value: item.id ?? 0, |
| | | })), |
| | | ); |
| | | const areaOptions = computed(() => |
| | | areas.value.map((item) => ({ |
| | | label: item.code ? `${item.code} ${item.name ?? ''}`.trim() : (item.name ?? ''), |
| | | value: item.id ?? 0, |
| | | })), |
| | | ); |
| | | const locationName = computed(() => locations.value.find((item) => item.id === locationId.value)?.name ?? ''); |
| | | const areaName = computed(() => areas.value.find((item) => item.id === areaId.value)?.name ?? ''); |
| | | const pageRoot = ref<HTMLElement>(); |
| | | let animationContext: gsap.Context | undefined; |
| | | let feedbackTimeline: gsap.core.Timeline | undefined; |
| | |
| | | try { |
| | | const [summary, page] = await Promise.all([ |
| | | getScanDashboard(), |
| | | getScanEventPage({ pageNo: 1, pageSize: 20, deviceCode: deviceCode.value.trim() || undefined }), |
| | | getScanEventPage({ pageNo: 1, pageSize: 20 }), |
| | | ]); |
| | | dashboard.value = summary; |
| | | records.value = (page.list ?? []).map((item, index) => ({ ...item, key: item.id ?? index })); |
| | |
| | | } |
| | | } |
| | | |
| | | async function loadOptions() { |
| | | try { |
| | | const [lineList, locationList] = await Promise.all([ |
| | | getLineSimpleList(), |
| | | getWarehouseLocationSimpleList(), |
| | | ]); |
| | | lines.value = lineList ?? []; |
| | | locations.value = locationList ?? []; |
| | | } catch { |
| | | lines.value = []; |
| | | locations.value = []; |
| | | } |
| | | } |
| | | |
| | | async function loadAreas() { |
| | | areas.value = []; |
| | | areaId.value = undefined; |
| | | if (!locationId.value) return; |
| | | try { |
| | | areas.value = (await getWarehouseAreaSimpleList(locationId.value)) ?? []; |
| | | } catch { |
| | | areas.value = []; |
| | | } |
| | | } |
| | | |
| | | function onScanTypeChange() { |
| | | code.value = ''; |
| | | } |
| | | |
| | | async function submitScan() { |
| | | const bagCode = code.value.trim(); |
| | | if (!bagCode || !deviceCode.value.trim()) { |
| | | message.warning('请å
å¡«å设å¤ç¼ç ï¼åæ«æè¢ç '); |
| | | if (!bagCode) { |
| | | message.warning('请å
æ«æè¿½æº¯ç '); |
| | | return; |
| | | } |
| | | if (!locationId.value || !areaId.value) { |
| | | message.warning('请å
é
ç½® WIP æååºåºååºä½'); |
| | | message.warning('请å
éæ© WIP æååºåºååºä½'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | await inboundScanEvent({ |
| | | eventId: `${deviceCode.value.trim()}-${Date.now()}`, |
| | | eventId: `${scanType.value}-${Date.now()}`, |
| | | bagCode, |
| | | scanType: scanType.value, |
| | | quantity: 1, |
| | | deviceCode: deviceCode.value.trim(), |
| | | lineCode: lineCode.value.trim() || undefined, |
| | | lineCode: lineCode.value || undefined, |
| | | locationId: locationId.value, |
| | | areaId: areaId.value, |
| | | }); |
| | | message.success(`è¢ç ${bagCode} 已宿å
¥åº`); |
| | | message.success(`${SCAN_TYPE_LABEL[scanType.value]} ${bagCode} 已宿å
¥åº`); |
| | | if (animationContext && pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { |
| | | feedbackTimeline?.kill(); |
| | | feedbackTimeline = gsap.timeline(); |
| | |
| | | code.value = ''; |
| | | await refresh(); |
| | | } catch (error) { |
| | | const response = error as { message?: string; msg?: string }; |
| | | message.error(response.message ?? response.msg ?? 'æ«ç å
¥åºå¤±è´¥ï¼è¯·æ¥çäºä»¶è®°å½'); |
| | | const response = error as { |
| | | message?: string; |
| | | msg?: string; |
| | | response?: { data?: { message?: string; msg?: string } }; |
| | | }; |
| | | const errorMessage = |
| | | response.response?.data?.message ?? |
| | | response.response?.data?.msg ?? |
| | | response.message ?? |
| | | response.msg; |
| | | if (errorMessage && /å
¥åºäºå¡æ°éå¿
é¡»ä¸ºæ£æ°|æ°é.*为 0|quantity.*positive/i.test(errorMessage)) { |
| | | message.error('å
¥åºæ°éä¸è½ä¸º 0ï¼è¯·æ£æ¥è¯¥æ¹æ¬¡çè¢æ°é
ç½®'); |
| | | } else if (!errorMessage || /UnexpectedRollbackException|ç³»ç»å¼å¸¸|Internal Server Error/i.test(errorMessage)) { |
| | | message.error('æ«ç å
¥åºå¤±è´¥ï¼è¯·æ£æ¥æ¹æ¬¡æ°éååºä½é
ç½®'); |
| | | } else { |
| | | message.error(errorMessage); |
| | | } |
| | | await refresh(); |
| | | } finally { |
| | | loading.value = false; |
| | |
| | | } |
| | | |
| | | async function refreshLatest() { |
| | | if (deviceCode.value.trim()) await getLatestScanEvent(deviceCode.value.trim()); |
| | | await refresh(); |
| | | } |
| | | |
| | |
| | | } |
| | | }, pageRoot.value); |
| | | } |
| | | await loadOptions(); |
| | | await refresh(); |
| | | timer = setInterval(refreshLatest, 15000); |
| | | }); |
| | |
| | | <div class="metric-card"><div class="metric-icon blue"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><span>累计æ«ç äºä»¶</span><strong>{{ dashboard.total ?? 0 }}</strong><small>æç»æ¥æ¶</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon green"><IconifyIcon icon="ant-design:check-circle-outlined" /></div><div><span>æåå
¥åº</span><strong>{{ successCount }}</strong><small>å·²åå
¥åºåäºå¡</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon red"><IconifyIcon icon="ant-design:warning-outlined" /></div><div><span>失败äºä»¶</span><strong>{{ failedCount }}</strong><small>éå
³æ³¨å¼å¸¸è®°å½</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon orange"><IconifyIcon icon="ant-design:inbox-outlined" /></div><div><span>æè¿å
¥åºè¢ç </span><strong class="text-value">{{ latest?.bagCode ?? '-' }}</strong><small>{{ latest?.eventTime ?? 'ææ è®°å½' }}</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon orange"><IconifyIcon icon="ant-design:inbox-outlined" /></div><div><span>æè¿å
¥åºè¿½æº¯ç </span><strong class="text-value">{{ latest?.bagCode ?? '-' }}</strong><small>{{ latest?.eventTime ?? 'ææ è®°å½' }}</small></div></div> |
| | | </section> |
| | | |
| | | <section class="main-grid"> |
| | | <a-card class="scan-card" :bordered="false"> |
| | | <template #title><div class="card-title"><IconifyIcon icon="ant-design:scan-outlined" />æ«ç å
¥åº <a-tag color="blue">èªå¨æ¨¡å¼</a-tag></div></template> |
| | | <div class="scan-panel"> |
| | | <div class="scan-intro"><div class="scan-ring"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><h2>çå¾
æ«ç </h2><p>å°è¢ç 对åå·¥ä¸ç¸æºï¼æä½¿ç¨æ«ç æªæ«æåå车确认</p></div></div> |
| | | <a-input-search v-model:value="code" class="scan-input" size="large" enter-button="确认å
¥åº" :loading="loading" :disabled="loading" placeholder="è¯·æ«æè¢ç ï¼æ«ç æªå车åèªå¨å
¥åº" @search="submitScan" /> |
| | | <div class="scan-hint"><IconifyIcon icon="ant-design:clock-circle-outlined" /> æå¡ç«¯è®°å½åç¡®å
¥åºæ¶é´ <span>·</span> <IconifyIcon icon="ant-design:inbox-outlined" /> æ¯è¢æ°éæ 1 计</div> |
| | | <div class="scan-intro"><div class="scan-ring"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><h2>çå¾
æ«ç </h2><p>éæ©è¿½æº¯ç ç±»ååæ«ç ï¼æä½¿ç¨æ«ç æªæ«æåå车确认</p></div></div> |
| | | <div class="scan-row"> |
| | | <a-select v-model:value="scanType" class="scan-type" :options="SCAN_TYPE_OPTIONS" size="large" @change="onScanTypeChange" /> |
| | | <a-input-search v-model:value="code" class="scan-input" size="large" enter-button="确认å
¥åº" :loading="loading" :disabled="loading" placeholder="è¯·æ«æè¿½æº¯ç ï¼æ«ç æªå车åèªå¨å
¥åº" @search="submitScan" /> |
| | | </div> |
| | | <div class="scan-hint"><IconifyIcon icon="ant-design:clock-circle-outlined" /> æå¡ç«¯è®°å½åç¡®å
¥åºæ¶é´ <span>·</span> <IconifyIcon icon="ant-design:inbox-outlined" /> è¢ç æ 1 è¢ãæçæå·²è£
è¢æ°ãæ¹æ¬¡ææ¹æ¬¡è¢æ°å
¥åº</div> |
| | | </div> |
| | | <a-divider /> |
| | | <a-form layout="vertical" class="config-form"> |
| | | <a-form-item label="设å¤ç¼ç " required><a-input v-model:value="deviceCode" prefix="设å¤" placeholder="ä¾å¦ï¼CAM-LINE-01" /></a-form-item> |
| | | <a-form-item label="产线ç¼ç "><a-input v-model:value="lineCode" placeholder="ä¾å¦ï¼LINE-A" /></a-form-item> |
| | | <a-form-item label="WIP æååºåº" required><a-input-number v-model:value="locationId" class="full-width" :min="1" placeholder="åºåºç¼å·" /></a-form-item> |
| | | <a-form-item label="WIP æååºä½" required><a-input-number v-model:value="areaId" class="full-width" :min="1" placeholder="åºä½ç¼å·" /></a-form-item> |
| | | <a-form-item label="ç产产线"><a-select v-model:value="lineCode" :options="lineOptions" allow-clear show-search option-filter-prop="label" placeholder="è¯·éæ©äº§çº¿" /></a-form-item> |
| | | <a-form-item label="WIP æååºåº" required><a-select v-model:value="locationId" :options="locationOptions" show-search option-filter-prop="label" placeholder="è¯·éæ©åºåº" @change="loadAreas" /></a-form-item> |
| | | <a-form-item label="WIP æååºä½" required><a-select v-model:value="areaId" :options="areaOptions" show-search option-filter-prop="label" :disabled="!locationId" placeholder="è¯·éæ©åºä½" /></a-form-item> |
| | | </a-form> |
| | | </a-card> |
| | | |
| | | <a-card class="status-card" :bordered="false"> |
| | | <template #title><div class="card-title"><ApiOutlined />éé设å¤ç¶æ</div></template> |
| | | <div class="device-state"><span class="online-badge"><span class="status-dot" aria-hidden="true" /><span>{{ isReady ? '设å¤å·²å°±ç»ª' : 'çå¾
é
ç½®' }}</span></span><span class="muted">15 ç§èªå¨å·æ°</span></div> |
| | | <div class="status-list"><div><span>å½å设å¤</span><strong>{{ deviceCode || 'æªé
ç½®' }}</strong></div><div><span>ç产产线</span><strong>{{ lineCode || 'æªé
ç½®' }}</strong></div><div><span>æåä½ç½®</span><strong>{{ locationId && areaId ? `${locationId} åº / ${areaId} ä½` : 'æªé
ç½®' }}</strong></div><div><span>æè¿å
¥åºæ¶é´</span><strong>{{ latest?.eventTime || '-' }}</strong></div><div><span>æè¿çäº§æ¹æ¬¡</span><strong>{{ latest?.batchCode || '-' }}</strong></div></div> |
| | | <div class="device-state"><span class="online-badge"><span class="status-dot" aria-hidden="true" /><span>{{ isReady ? 'ä½ç½®å·²å°±ç»ª' : 'çå¾
é
ç½®' }}</span></span><span class="muted">15 ç§èªå¨å·æ°</span></div> |
| | | <div class="status-list"><div><span>ç产产线</span><strong>{{ lineCode || 'æªé
ç½®' }}</strong></div><div><span>æåä½ç½®</span><strong>{{ locationId && areaId ? `${locationName || locationId} / ${areaName || areaId}` : 'æªé
ç½®' }}</strong></div><div><span>æè¿å
¥åºæ¶é´</span><strong>{{ latest?.eventTime || '-' }}</strong></div><div><span>æè¿çäº§æ¹æ¬¡</span><strong>{{ latest?.batchCode || '-' }}</strong></div><div><span>æè¿æ«ç ç±»å</span><strong>{{ latest?.scanType ? SCAN_TYPE_LABEL[latest.scanType] : '-' }}</strong></div></div> |
| | | <div class="sync-box"><IconifyIcon icon="ant-design:check-circle-outlined" /><div><strong>ä»å¨ç³»ç»åæ¥æ£å¸¸</strong><span>æ«ç äºä»¶ä¸åºåäºå¡å®æ¶åå
¥</span></div></div> |
| | | </a-card> |
| | | </section> |
| | |
| | | <a-card class="records-card" :bordered="false"> |
| | | <template #title><div class="card-title"><IconifyIcon icon="ant-design:clock-circle-outlined" />æè¿æ«ç äºä»¶ <span class="record-count">æè¿ 20 æ¡</span></div></template> |
| | | <a-table :data-source="records" :pagination="false" :scroll="{ x: 1050 }" row-key="key" size="middle"> |
| | | <a-table-column key="bagCode" title="è¢ç " data-index="bagCode" /><a-table-column key="batchCode" title="çäº§æ¹æ¬¡" data-index="batchCode" /><a-table-column key="deviceCode" title="è®¾å¤ / 产线"><template #default="{ record }">{{ record.deviceCode || '-' }}<span class="sub-text">{{ record.lineCode || '-' }}</span></template></a-table-column> |
| | | <a-table-column key="bagCode" title="追溯ç " data-index="bagCode" /><a-table-column key="scanType" title="ç±»å"><template #default="{ record }">{{ record.scanType ? SCAN_TYPE_LABEL[record.scanType as MesProScanEventApi.ScanType] : 'è¢ç ' }}</template></a-table-column><a-table-column key="batchCode" title="çäº§æ¹æ¬¡" data-index="batchCode" /><a-table-column key="lineCode" title="产线"><template #default="{ record }">{{ record.lineCode || '-' }}</template></a-table-column> |
| | | <a-table-column key="status" title="ç¶æ"><template #default="{ record }"><a-tag :color="record.status === 'SUCCESS' ? 'success' : 'error'">{{ record.status === 'SUCCESS' ? 'æåå
¥åº' : '失败' }}</a-tag></template></a-table-column><a-table-column key="errorCode" title="é误ç " data-index="errorCode" /><a-table-column key="errorMessage" title="失败åå " data-index="errorMessage" /><a-table-column key="eventTime" title="å¤çæ¶é´" data-index="eventTime" /><a-table-column key="transactionId" title="åºåäºå¡" data-index="transactionId" /> |
| | | </a-table> |
| | | </a-card> |
| | |
| | | .header-status, .device-state { display: flex; align-items: center; gap: 10px; color: #16835b; font-size: 13px; font-weight: 600; }.header-status :deep(.ant-btn) { color: #607089; } |
| | | .status-dot { display: inline-block; flex: 0 0 8px; width: 8px; height: 8px; margin: 0 2px 0 1px; border-radius: 50%; background: #20b878; box-shadow: 0 0 0 4px #d9f7e9; transform-origin: center; } |
| | | .metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 18px; }.metric-card { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px; background: #fff; border: 1px solid #e8edf5; border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.metric-card > div:last-child { min-width: 0; padding-top: 1px; }.metric-icon { flex: 0 0 42px; display: grid; place-items: center; width: 42px; height: 42px; margin-top: 1px; border-radius: 9px; font-size: 20px; }.metric-icon.blue { color: #1677ff; background: #eaf3ff; }.metric-icon.green { color: #17a673; background: #e6f8f0; }.metric-icon.red { color: #e05252; background: #fff0f0; }.metric-icon.orange { color: #d58a17; background: #fff6df; }.metric-card span, .metric-card small { display: block; color: #7b8799; font-size: 12px; }.metric-card strong { display: block; margin: 4px 0; font-size: 25px; line-height: 1.1; }.metric-card .text-value { overflow: hidden; max-width: 180px; text-overflow: ellipsis; white-space: nowrap; font-size: 18px; } |
| | | .main-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .8fr); gap: 18px; margin-bottom: 18px; }.scan-card, .status-card, .records-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 10px; min-height: 24px; font-weight: 700; }.card-title :deep(.iconify) { flex: 0 0 auto; margin-top: 1px; }.card-title :deep(.anticon) { color: #1677ff; }.card-title .ant-tag { margin-left: 4px; font-weight: 500; }.scan-panel { padding: 8px 0 4px; }.scan-intro { display: flex; align-items: center; gap: 16px; margin: 4px 0 22px; }.scan-ring { display: grid; place-items: center; width: 58px; height: 58px; margin: 4px 2px 4px 4px; border: 1px solid #b9d5ff; border-radius: 50%; color: #1677ff; background: #f1f7ff; font-size: 25px; }.scan-intro h2 { margin: 0 0 7px; font-size: 19px; line-height: 1.35; }.scan-intro p { margin: 0; color: #7b8799; font-size: 13px; }.scan-input :deep(input) { height: 50px; font-size: 16px; }.scan-input :deep(.ant-input-group-addon .ant-btn) { height: 50px; font-weight: 600; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 15px; padding-left: 2px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint span { margin: 0 2px; color: #b7c0cd; }.config-form { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: 18px; }.config-form :deep(.ant-form-item) { margin-bottom: 14px; }.full-width { width: 100%; } |
| | | .main-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .8fr); gap: 18px; margin-bottom: 18px; }.scan-card, .status-card, .records-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 10px; min-height: 24px; font-weight: 700; }.card-title :deep(.iconify) { flex: 0 0 auto; margin-top: 1px; }.card-title :deep(.anticon) { color: #1677ff; }.card-title .ant-tag { margin-left: 4px; font-weight: 500; }.scan-panel { padding: 8px 0 4px; }.scan-intro { display: flex; align-items: center; gap: 16px; margin: 4px 0 22px; }.scan-ring { display: grid; place-items: center; width: 58px; height: 58px; margin: 4px 2px 4px 4px; border: 1px solid #b9d5ff; border-radius: 50%; color: #1677ff; background: #f1f7ff; font-size: 25px; }.scan-intro h2 { margin: 0 0 7px; font-size: 19px; line-height: 1.35; }.scan-intro p { margin: 0; color: #7b8799; font-size: 13px; }.scan-row { display: flex; align-items: stretch; gap: 10px; }.scan-type { flex: 0 0 150px; }.scan-type :deep(.ant-select-selector) { height: 50px !important; }.scan-type :deep(.ant-select-selection-item) { line-height: 48px !important; }.scan-input { flex: 1 1 auto; min-width: 0; }.scan-input :deep(input) { height: 50px; font-size: 16px; }.scan-input :deep(.ant-input-group-addon .ant-btn) { height: 50px; font-weight: 600; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 15px; padding-left: 2px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint span { margin: 0 2px; color: #b7c0cd; }.config-form { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: 18px; }.config-form :deep(.ant-form-item) { margin-bottom: 14px; }.full-width { width: 100%; } |
| | | .device-state { justify-content: space-between; margin-bottom: 20px; }.online-badge { display: inline-flex; align-items: center; gap: 10px; min-height: 24px; color: #16835b; white-space: nowrap; }.muted, .sub-text { color: #8793a5; font-size: 12px; }.status-list { border-top: 1px solid #edf0f5; }.status-list div { display: flex; justify-content: space-between; gap: 12px; padding: 13px 0; border-bottom: 1px solid #edf0f5; font-size: 13px; }.status-list span { color: #7b8799; }.status-list strong { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }.sync-box { display: flex; gap: 10px; margin-top: 20px; padding: 12px; border-radius: 7px; color: #16835b; background: #effaf5; }.sync-box div { display: flex; flex-direction: column; gap: 3px; }.sync-box span { color: #5f8b76; font-size: 12px; }.record-count { margin-left: 4px; color: #9aa5b5; font-size: 12px; font-weight: 400; }.sub-text { display: block; margin-top: 3px; } |
| | | @media (max-width: 900px) { .metric-grid { grid-template-columns: repeat(2, 1fr); }.main-grid { grid-template-columns: 1fr; } } |
| | | @media (max-width: 600px) { .scan-page { padding: 14px; }.page-header { display: block; }.header-status { margin-top: 14px; }.metric-grid { grid-template-columns: 1fr; }.config-form { grid-template-columns: 1fr; } h1 { font-size: 23px; } } |
| | | @media (max-width: 600px) { .scan-page { padding: 14px; }.page-header { display: block; }.header-status { margin-top: 14px; }.metric-grid { grid-template-columns: 1fr; }.config-form { grid-template-columns: 1fr; }.scan-row { flex-wrap: wrap; }.scan-type { flex-basis: 100%; } h1 { font-size: 23px; } } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed } from 'vue'; |
| | | import type { MesTraceBatchApi } from '#/api/mes/trace/batch'; |
| | | |
| | | const props = defineProps<{ detail: MesTraceBatchApi.Detail }>(); |
| | | const decision = computed(() => props.detail.decision); |
| | | const qualityRisk = computed(() => decision.value?.qualityRisk ?? props.detail.quality?.result === 2); |
| | | const riskLevel = computed(() => decision.value?.riskLevel || (qualityRisk.value ? 'HIGH' : 'NORMAL')); |
| | | const riskText = computed(() => qualityRisk.value ? 'é«é£é©' : riskLevel.value === 'NORMAL' ? 'æ£å¸¸' : riskLevel.value); |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="grid gap-4 p-4 md:grid-cols-3"> |
| | | <div class="rounded border p-4"> |
| | | <div class="text-muted-foreground text-sm">è´¨éå³ç</div> |
| | | <div class="mt-2 text-lg font-semibold" :class="qualityRisk ? 'text-red-600' : 'text-green-600'"> |
| | | {{ detail.quality?.resultName || 'å¾
æ£éª' }} |
| | | </div> |
| | | <div class="text-muted-foreground mt-2 text-sm">{{ detail.quality?.reason || 'ææ å¼å¸¸è¯´æ' }}</div> |
| | | </div> |
| | | <div class="rounded border p-4"> |
| | | <div class="text-muted-foreground text-sm">åºåå³ç</div> |
| | | <div class="mt-2 text-lg font-semibold">å
¥åº {{ decision?.inboundQuantity ?? detail.inbound?.quantity ?? 0 }}</div> |
| | | <div class="text-muted-foreground mt-2 text-sm">已建ç«éå®å»å {{ decision?.salesTargetCount ?? detail.salesTargets?.length ?? 0 }} æ¡</div> |
| | | </div> |
| | | <div class="rounded border p-4"> |
| | | <div class="text-muted-foreground text-sm">é²ä¼ªå³ç</div> |
| | | <div class="mt-2 text-lg font-semibold">{{ detail.antiFake?.scanCount ?? 0 }} 次</div> |
| | | <div class="text-muted-foreground mt-2 text-sm">æ¶è´¹è
éªè¯æ¬¡æ°</div> |
| | | </div> |
| | | </div> |
| | | <div class="mx-4 mb-4 rounded border p-4" :class="qualityRisk ? 'border-red-200 bg-red-50' : 'border-green-200 bg-green-50'"> |
| | | <div class="flex items-center justify-between"> |
| | | <div class="font-medium">综åé£é©ç级</div> |
| | | <a-tag :color="qualityRisk ? 'red' : riskLevel === 'NORMAL' ? 'green' : 'orange'">{{ riskText }}</a-tag> |
| | | </div> |
| | | </div> |
| | | <div class="mx-4 mb-4 rounded border p-4"> |
| | | <div class="mb-3 font-medium">责任é¾</div> |
| | | <div class="grid gap-2 text-sm md:grid-cols-3"> |
| | | <div>责任人ï¼{{ detail.ownerUserName || 'æªè®°å½' }}</div> |
| | | <div>ç产线ï¼{{ detail.lineName || detail.lineCode || 'æªè®°å½' }}</div> |
| | | <div>ç产人åï¼{{ detail.workers?.length ?? 0 }} 人</div> |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { EchartsUIType, EChartsOption } from '@vben/plugins/echarts'; |
| | | import type { MesTraceBatchApi } from '#/api/mes/trace/batch'; |
| | | |
| | | import { nextTick, onMounted, ref, watch } from 'vue'; |
| | | |
| | | import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
| | | |
| | | const props = defineProps<{ detail: MesTraceBatchApi.Detail }>(); |
| | | const chartRef = ref<EchartsUIType>(); |
| | | const { renderEcharts } = useEcharts(chartRef); |
| | | const render = () => renderEcharts(buildOption()); |
| | | |
| | | // use-echarts å¨ onMounted åæå° isActive 置为 trueï¼ |
| | | // æ
渲æå¿
é¡»æ¾å° onMountedï¼immediate watch å¨ setup é¶æ®µä¼è¢«éé»ä¸¢å¼ï¼ |
| | | onMounted(() => { |
| | | void nextTick(render); |
| | | }); |
| | | watch(() => props.detail, () => void render(), { deep: true }); |
| | | |
| | | const buildOption = (): EChartsOption => { |
| | | const nodes: { id: string; name: string; category: number; symbolSize: number }[] = []; |
| | | const links: { source: string; target: string }[] = []; |
| | | const add = (id: string, name: string, category: number, symbolSize = 42) => { |
| | | if (!nodes.some((node) => node.id === id)) nodes.push({ id, name, category, symbolSize }); |
| | | }; |
| | | const connect = (source: string, target: string) => { |
| | | if (source !== target && !links.some((link) => link.source === source && link.target === target)) links.push({ source, target }); |
| | | }; |
| | | const root = `batch:${props.detail.batchCode || props.detail.requestCode || 'unknown'}`; |
| | | add(root, props.detail.batchCode || 'çäº§æ¹æ¬¡', 1, 58); |
| | | (props.detail.purchaseSources ?? []).slice(0, 12).forEach((item, index) => { const id = `source:${index}`; add(id, item.vendorName || item.receiptCode || 'éè´æ¥æº', 0); connect(id, root); }); |
| | | if (props.detail.quality) { add('quality', props.detail.quality.resultName || 'è´¨éæ£éª', 2); connect(root, 'quality'); } |
| | | add('warehouse', 'æ«ç å
¥åº', 3); connect(root, 'warehouse'); |
| | | (props.detail.salesTargets ?? []).slice(0, 12).forEach((item, index) => { const id = `sales:${index}`; add(id, item.clientName || item.salesCode || 'éå®å»å', 4); connect('warehouse', id); }); |
| | | if (props.detail.antiFake?.scanCount) { add('consumer', `æ¶è´¹è
éªè¯\n${props.detail.antiFake.scanCount} 次`, 5); connect('warehouse', 'consumer'); } |
| | | const series = { type: 'graph' as const, layout: 'force' as const, roam: true, draggable: true, data: nodes, links, categories: ['æ¥æº', 'çäº§æ¹æ¬¡', 'è´¨é', 'ä»å¨', 'éå®', 'æ¶è´¹'].map((name) => ({ name })), label: { show: true, fontSize: 12 }, force: { repulsion: 260, edgeLength: 110 }, lineStyle: { color: '#94a3b8', curveness: 0.15 } }; |
| | | return { tooltip: { trigger: 'item' }, legend: [{ data: ['æ¥æº', 'çäº§æ¹æ¬¡', 'è´¨é', 'ä»å¨', 'éå®', 'æ¶è´¹'] }], series: [series] }; |
| | | }; |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="p-4"> |
| | | <EchartsUI ref="chartRef" :height="'420px'" class="w-full rounded border bg-muted/20" /> |
| | | <div class="text-muted-foreground mt-2 text-center text-xs">å¾è°±é»è®¤å±ç¤ºæ¹æ¬¡çº§å
³ç³»ï¼æ¥æºåéå®èç¹åæå¤ 12 æ¡</div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, onMounted, onUnmounted, ref } from 'vue'; |
| | | import { gsap } from 'gsap'; |
| | | import type { MesTraceBatchApi } from '#/api/mes/trace/batch'; |
| | | |
| | | const props = defineProps<{ detail: MesTraceBatchApi.Detail }>(); |
| | | const qualityRisk = computed(() => props.detail.decision?.qualityRisk ?? props.detail.quality?.result === 2); |
| | | const riskLevel = computed(() => props.detail.decision?.riskLevel || (qualityRisk.value ? 'HIGH' : 'NORMAL')); |
| | | const containerRef = ref<HTMLElement>(); |
| | | let animationContext: gsap.Context | undefined; |
| | | |
| | | onMounted(() => { |
| | | if (!containerRef.value) return; |
| | | animationContext = gsap.context(() => { |
| | | gsap.from('.trace-stage-card', { autoAlpha: 0, y: 12, duration: 0.3, stagger: 0.04, ease: 'power2.out' }); |
| | | }, containerRef.value); |
| | | }); |
| | | |
| | | onUnmounted(() => animationContext?.revert()); |
| | | </script> |
| | | |
| | | <template> |
| | | <div ref="containerRef" class="grid grid-cols-2 gap-3 p-4 lg:grid-cols-4"> |
| | | <div v-for="stage in [ |
| | | { name: 'æ¥æº', value: detail.purchaseSources?.length ? '已建ç«' : 'å¾
è¡¥å
' }, |
| | | { name: 'çäº§æ¹æ¬¡', value: detail.batchStatusName || 'æªç¥' }, |
| | | { name: 'è´¨éæ£éª', value: detail.quality?.resultName || 'å¾
æ£éª' }, |
| | | { name: 'å
è£
èµç ', value: `${detail.bagTotal ?? 0} è¢ / ${detail.palletCount ?? 0} æ` }, |
| | | { name: 'ä»å¨å
¥åº', value: detail.inbound ? 'å·²å
¥åº' : 'æªå
¥åº' }, |
| | | { name: 'éå®å»å', value: `${detail.salesTargets?.length ?? 0} æ¡` }, |
| | | { name: 'æ¶è´¹è
éªè¯', value: `${detail.antiFake?.scanCount ?? 0} 次æ«ç ` }, |
| | | { name: 'é£é©ç¶æ', value: qualityRisk ? 'è´¨éå¼å¸¸' : riskLevel === 'NORMAL' ? 'æ£å¸¸' : riskLevel }, |
| | | ]" :key="stage.name" class="trace-stage-card rounded border bg-white p-4 shadow-sm"> |
| | | <div class="text-muted-foreground text-sm">{{ stage.name }}</div> |
| | | <div class="mt-2 text-base font-semibold" :class="stage.name === 'é£é©ç¶æ' ? qualityRisk ? 'text-red-600' : riskLevel === 'NORMAL' ? 'text-green-600' : 'text-orange-600' : ''"> |
| | | {{ stage.value }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed } from 'vue'; |
| | | import type { MesTraceBatchApi } from '#/api/mes/trace/batch'; |
| | | |
| | | const props = defineProps<{ detail: MesTraceBatchApi.Detail }>(); |
| | | |
| | | const formatTime = (value?: string) => value ? new Date(value).toLocaleString() : 'æªè®°å½'; |
| | | |
| | | const timeline = computed(() => { |
| | | if (props.detail.timeline?.length) return props.detail.timeline; |
| | | return [ |
| | | ...(props.detail.purchaseSources?.length ? [{ stage: 'æ¥æº', eventName: 'éè´æ¥æº', status: `${props.detail.purchaseSources.length} æ¡è®°å½` }] : []), |
| | | { stage: 'ç产', eventType: 'BATCH_CREATED', eventName: 'çäº§æ¹æ¬¡å»ºç«', eventTime: props.detail.produceDate, status: props.detail.batchStatusName }, |
| | | { stage: 'å
è£
', eventName: 'å
è£
èµç ', quantity: props.detail.bagTotal }, |
| | | ...(props.detail.inbound ? [{ stage: 'ä»å¨', eventType: 'INBOUND', eventName: 'ä»å¨å
¥åº', eventTime: props.detail.inbound.eventTime, quantity: props.detail.inbound.quantity, status: props.detail.inbound.status }] : []), |
| | | ...(props.detail.salesTargets ?? []).map((item) => ({ stage: 'éå®', eventType: 'OUTBOUND', eventName: `éå®å»å · ${item.clientName || 'æªè®°å½å®¢æ·'}`, eventTime: item.salesDate, quantity: item.quantity, status: item.qualityStatus == null ? undefined : String(item.qualityStatus) })), |
| | | ...(props.detail.antiFake?.scanCount ? [{ stage: 'æ¶è´¹', eventType: 'CONSUMER_SCANNED', eventName: 'æ¶è´¹è
éªè¯', eventTime: props.detail.antiFake.lastScanTime, quantity: props.detail.antiFake.scanCount, status: `累计æ«ç ${props.detail.antiFake.scanCount} 次` }] : []), |
| | | ]; |
| | | }); |
| | | |
| | | const colorMap: Record<string, string> = { æ¥æº: 'blue', ç产: 'blue', å
è£
: 'purple', ä»å¨: 'green', éå®: 'orange', æ¶è´¹: 'purple', é£é©: 'red' }; |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="p-4"> |
| | | <a-timeline> |
| | | <a-timeline-item v-for="(item, index) in timeline" :key="`${item.eventType || item.stage}-${index}`" :color="colorMap[item.stage || 'ç产'] || 'blue'"> |
| | | <div class="font-medium">{{ item.eventName || '追溯äºä»¶' }}</div> |
| | | <div class="text-muted-foreground text-sm"> |
| | | {{ item.stage || 'æªåç±»' }} · æ°éï¼{{ item.quantity ?? 'â' }} · {{ formatTime(item.eventTime) }} |
| | | <span v-if="item.status"> · {{ item.status }}</span> |
| | | </div> |
| | | </a-timeline-item> |
| | | <a-empty v-if="!timeline.length" description="ææ è¿½æº¯äºä»¶" /> |
| | | </a-timeline> |
| | | </div> |
| | | </template> |
| | |
| | | <script lang="ts" setup> |
| | | import type { MesWmBatchApi } from '#/api/mes/wm/batch'; |
| | | import type { MesTraceBatchApi } from '#/api/mes/trace/batch'; |
| | | |
| | | import { ref } from 'vue'; |
| | | import { computed, ref } from 'vue'; |
| | | |
| | | import { useVbenModal } from '@vben/common-ui'; |
| | | |
| | | import { Tabs, Tooltip } from 'ant-design-vue'; |
| | | import { Descriptions, Spin, Tabs, Tag, message } from 'ant-design-vue'; |
| | | |
| | | import { useDescription } from '#/components/description'; |
| | | import { getBatchTraceDetail } from '#/api/mes/trace/batch'; |
| | | |
| | | import { useDetailSchema } from '../data'; |
| | | import ProcessTraceList from './process-trace-list.vue'; |
| | | import PurchaseSourceList from './purchase-source-list.vue'; |
| | | import SalesTargetList from './sales-target-list.vue'; |
| | | import TraceList from './trace-list.vue'; |
| | | import TraceDecision from './TraceDecision.vue'; |
| | | import TraceGraph from './TraceGraph.vue'; |
| | | import TraceStageOverview from './TraceStageOverview.vue'; |
| | | import TraceTimeline from './TraceTimeline.vue'; |
| | | |
| | | const detailData = ref<MesWmBatchApi.Batch>(); // å½åæ¹æ¬¡è¯¦æ
|
| | | const subTabsName = ref< |
| | | | 'backward' |
| | | | 'forward' |
| | | | 'purchase' |
| | | | 'sales' |
| | | | 'process' |
| | | >('forward'); // å½åæ¿æ´»çè¿½æº¯é¡µç¾ |
| | | |
| | | const [Descriptions] = useDescription({ |
| | | bordered: true, |
| | | column: 3, |
| | | schema: useDetailSchema(), |
| | | const detailData = ref<MesTraceBatchApi.Detail>(); |
| | | const loading = ref(false); |
| | | const activeTab = ref('overview'); |
| | | const formatDate = (value?: string) => value ? new Date(value).toLocaleString() : 'æªè®°å½'; |
| | | const riskLevel = computed(() => { |
| | | if (!detailData.value) return 'NORMAL'; |
| | | return detailData.value.decision?.riskLevel || (detailData.value.quality?.result === 2 ? 'HIGH' : 'NORMAL'); |
| | | }); |
| | | const riskLabel = computed(() => riskLevel.value === 'HIGH' ? 'é«é£é©' : riskLevel.value === 'NORMAL' ? 'æ£å¸¸' : 'éå
³æ³¨'); |
| | | const riskColor = computed(() => riskLevel.value === 'HIGH' ? 'red' : riskLevel.value === 'NORMAL' ? 'green' : 'orange'); |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | onOpenChange(isOpen) { |
| | |
| | | detailData.value = undefined; |
| | | return; |
| | | } |
| | | detailData.value = modalApi.getData<MesWmBatchApi.Batch>(); |
| | | subTabsName.value = 'forward'; |
| | | const row = modalApi.getData<{ code?: string }>(); |
| | | if (row?.code) { |
| | | void loadDetail(row.code); |
| | | } |
| | | activeTab.value = 'overview'; |
| | | }, |
| | | }); |
| | | |
| | | async function loadDetail(code: string) { |
| | | loading.value = true; |
| | | try { |
| | | detailData.value = await getBatchTraceDetail(code); |
| | | } catch { |
| | | message.error('æ¹æ¬¡è¿½æº¯è¯¦æ
å 载失败'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <Modal |
| | | title="æ¹æ¬¡è¿½æº¯" |
| | | class="w-4/5" |
| | | title="å
¨é¾è·¯æ¹æ¬¡è¿½æº¯" |
| | | class="w-11/12" |
| | | :show-cancel-button="false" |
| | | :show-confirm-button="false" |
| | | > |
| | | <Descriptions class="mx-4 mt-2" :data="detailData" /> |
| | | <Tabs |
| | | v-if="detailData?.code" |
| | | v-model:active-key="subTabsName" |
| | | class="mx-4 mt-4" |
| | | type="card" |
| | | > |
| | | <Tabs.TabPane key="forward"> |
| | | <template #tab> |
| | | <Tooltip title="顺çç产æµå¾ä¸æ¸¸æ¥ï¼è¯¥æ¹æ¬¡è¢«åªäºå·¥åæ¶èåï¼äº§åºæäºåªäºæåæ¹æ¬¡"> |
| | | <span>åå追溯</span> |
| | | </Tooltip> |
| | | </template> |
| | | <TraceList :batch-code="detailData.code" direction="forward" /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="backward"> |
| | | <template #tab> |
| | | <Tooltip title="éçç产æµå¾ä¸æ¸¸æ¥ï¼çäº§è¯¥æ¹æ¬¡æ¶ï¼æ¶èäºåªäºåææ¹æ¬¡"> |
| | | <span>åå追溯</span> |
| | | </Tooltip> |
| | | </template> |
| | | <TraceList :batch-code="detailData.code" direction="backward" /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="purchase"> |
| | | <template #tab> |
| | | <Tooltip title="åæä¾§æ¥æºï¼è¯¥æ¹æ¬¡å¯¹åºçéè´å
¥åºåä¸ IQC æ¥ææ£éªè®°å½"> |
| | | <span>éè´æ¥æº</span> |
| | | </Tooltip> |
| | | </template> |
| | | <PurchaseSourceList :batch-code="detailData.code" /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="sales"> |
| | | <template #tab> |
| | | <Tooltip title="æåä¾§å»åï¼è¯¥æ¹æ¬¡å¯¹åºçéå®åºåºåä¸ OQC åºåæ£éªè®°å½"> |
| | | <span>éå®å»å</span> |
| | | </Tooltip> |
| | | </template> |
| | | <SalesTargetList :batch-code="detailData.code" /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="process"> |
| | | <template #tab> |
| | | <Tooltip title="åå·¥åºçæå
¥/产åºäºä»¶ï¼è¯¥æ¹æ¬¡å¨åªäºå·¥åºè¢«æå
¥æäº§åºï¼åæ°éåè´¨éç¶æ"> |
| | | <span>å·¥åºæµè½¬</span> |
| | | </Tooltip> |
| | | </template> |
| | | <ProcessTraceList :batch-code="detailData.code" /> |
| | | </Tabs.TabPane> |
| | | </Tabs> |
| | | <Spin :spinning="loading"> |
| | | <div v-if="detailData" class="px-2"> |
| | | <div class="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-5"> |
| | | <div class="rounded border bg-blue-50 px-4 py-3"><div class="text-muted-foreground text-xs">è¢ç æ°é</div><div class="mt-1 text-lg font-semibold text-blue-700">{{ detailData.bagTotal ?? 0 }}</div></div> |
| | | <div class="rounded border bg-violet-50 px-4 py-3"><div class="text-muted-foreground text-xs">æçæ°é</div><div class="mt-1 text-lg font-semibold text-violet-700">{{ detailData.palletCount ?? 0 }}</div></div> |
| | | <div class="rounded border bg-green-50 px-4 py-3"><div class="text-muted-foreground text-xs">å
¥åºæ°é</div><div class="mt-1 text-lg font-semibold text-green-700">{{ detailData.decision?.inboundQuantity ?? detailData.inbound?.quantity ?? 0 }}</div></div> |
| | | <div class="rounded border bg-orange-50 px-4 py-3"><div class="text-muted-foreground text-xs">éå®å»å</div><div class="mt-1 text-lg font-semibold text-orange-700">{{ detailData.decision?.salesTargetCount ?? detailData.salesTargets?.length ?? 0 }}</div></div> |
| | | <div class="col-span-2 rounded border px-4 py-3 lg:col-span-1" :class="riskLevel === 'HIGH' ? 'border-red-200 bg-red-50' : riskLevel === 'NORMAL' ? 'border-green-200 bg-green-50' : 'border-orange-200 bg-orange-50'"><div class="text-muted-foreground text-xs">综åé£é©</div><div class="mt-1"><Tag :color="riskColor">{{ riskLabel }}</Tag></div></div> |
| | | </div> |
| | | <Descriptions bordered :column="{ xs: 1, sm: 2, md: 3 }" size="small"> |
| | | <Descriptions.Item label="追溯å
¥å£">{{ detailData.requestCode || 'æªè®°å½' }}</Descriptions.Item> |
| | | <Descriptions.Item label="å
¥å£ç±»å"><Tag color="blue">{{ detailData.sourceType || 'BATCH' }}</Tag></Descriptions.Item> |
| | | <Descriptions.Item label="æ¹æ¬¡ç¶æ">{{ detailData.batchStatusName || 'æªç¥' }}</Descriptions.Item> |
| | | <Descriptions.Item label="åç§">{{ detailData.itemName || detailData.itemCode || 'æªè®°å½' }}</Descriptions.Item> |
| | | <Descriptions.Item label="æ¹æ¬¡å·">{{ detailData.batchCode || 'æªè®°å½' }}</Descriptions.Item> |
| | | <Descriptions.Item label="责任人">{{ detailData.ownerUserName || 'æªè®°å½' }}</Descriptions.Item> |
| | | <Descriptions.Item label="ç产线">{{ detailData.lineName || detailData.lineCode || 'æªè®°å½' }}</Descriptions.Item> |
| | | <Descriptions.Item label="è®¡åæ°é">{{ detailData.planQuantity ?? 0 }}</Descriptions.Item> |
| | | <Descriptions.Item label="çäº§æ¥æ">{{ formatDate(detailData.produceDate) }}</Descriptions.Item> |
| | | <Descriptions.Item label="å
¥åºæ¶é´">{{ formatDate(detailData.inboundTime || detailData.inbound?.eventTime) }}</Descriptions.Item> |
| | | </Descriptions> |
| | | |
| | | <Tabs v-model:active-key="activeTab" class="mt-4" type="card"> |
| | | <Tabs.TabPane key="overview" tab="é¶æ®µæ»è§"> |
| | | <TraceStageOverview :detail="detailData" /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="timeline" tab="å
¨é¾è·¯æ¶é´çº¿"> |
| | | <TraceTimeline :detail="detailData" /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="graph" tab="æµå¨å¾è°±"> |
| | | <TraceGraph :detail="detailData" /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="decision" tab="å³çåæ"> |
| | | <TraceDecision :detail="detailData" /> |
| | | </Tabs.TabPane> |
| | | </Tabs> |
| | | </div> |
| | | <div v-else-if="!loading" class="py-12 text-center text-muted-foreground">ææ è¿½æº¯æ°æ®</div> |
| | | </Spin> |
| | | </Modal> |
| | | </template> |
| | |
| | | |
| | | import { message, Modal } from 'ant-design-vue'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { MesWmWarehouseTypeEnum } from '@vben/constants'; |
| | | import { gsap } from 'gsap'; |
| | | |
| | | import MdItemSelect from '#/views/mes/md/item/components/select.vue'; |
| | | |
| | | import { |
| | | submitSeedQuality, |
| | | type MesQcSeedQualityApi, |
| | | } from '#/api/mes/qc/seedQuality'; |
| | | import type { MesWmWarehouseApi } from '#/api/mes/wm/warehouse'; |
| | | import { submitSeedQuality } from '#/api/mes/qc/seedQuality'; |
| | | import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse'; |
| | | |
| | | interface IndicatorForm { |
| | | code: string; |
| | |
| | | |
| | | const pageRoot = ref<HTMLElement>(); |
| | | const code = ref(''); |
| | | const itemId = ref<number>(); |
| | | const batchCode = ref(''); |
| | | const reason = ref(''); |
| | | const loading = ref(false); |
| | | const submittedId = ref<number>(); |
| | | let animationContext: gsap.Context | undefined; |
| | | |
| | | /** å¯éåæ ¼ä»åºï¼ä»åºç±»å=20 åæ ¼åºï¼ */ |
| | | const qualifiedWarehouses = ref<MesWmWarehouseApi.Warehouse[]>([]); |
| | | const qualifiedWarehouseId = ref<number>(); |
| | | |
| | | const indicators = ref<IndicatorForm[]>([ |
| | | { code: 'purity', label: '纯度', unit: '%', minValue: 0, maxValue: 100 }, |
| | |
| | | |
| | | function resetForm(clearResult = true) { |
| | | code.value = ''; |
| | | itemId.value = undefined; |
| | | batchCode.value = ''; |
| | | reason.value = ''; |
| | | if (clearResult) submittedId.value = undefined; |
| | |
| | | } |
| | | |
| | | async function submit() { |
| | | if (!code.value.trim() || !itemId.value) { |
| | | message.warning('请填åè¢ç å¹¶éæ©ç©æ'); |
| | | if (!code.value.trim()) { |
| | | message.warning('请填åè¢ç '); |
| | | return; |
| | | } |
| | | if (!allEntered.value) { |
| | | message.warning('请å
å¡«ååé¡¹ææ ç宿µå¼'); |
| | | return; |
| | | } |
| | | if (!qualifiedWarehouseId.value) { |
| | | message.warning('è¯·éæ©å
¥åºåæ ¼åº'); |
| | | return; |
| | | } |
| | | if (indicators.value.some((item) => item.minValue === undefined || item.maxValue === undefined || item.minValue > item.maxValue)) { |
| | |
| | | try { |
| | | submittedId.value = await submitSeedQuality({ |
| | | code: code.value.trim(), |
| | | itemId: itemId.value, |
| | | batchCode: batchCode.value.trim() || undefined, |
| | | indicators: indicators.value.map(({ code: indicatorCode, value, minValue, maxValue }) => ({ |
| | | code: indicatorCode, |
| | |
| | | maxValue: maxValue as number, |
| | | })), |
| | | reason: reason.value.trim() || undefined, |
| | | qualifiedWarehouseId: qualifiedWarehouseId.value, |
| | | }); |
| | | message.success(`è´¨æ£æäº¤æåï¼è´¨æ£ç¼å·ï¼${submittedId.value}`); |
| | | if (pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { |
| | |
| | | } |
| | | |
| | | onMounted(async () => { |
| | | try { |
| | | const warehouses = await getWarehouseSimpleList(); |
| | | qualifiedWarehouses.value = (warehouses ?? []).filter( |
| | | (item) => item.type === MesWmWarehouseTypeEnum.QUALIFIED, |
| | | ); |
| | | } catch { |
| | | qualifiedWarehouses.value = []; |
| | | } |
| | | await nextTick(); |
| | | if (!pageRoot.value) return; |
| | | animationContext = gsap.context(() => { |
| | |
| | | <a-form layout="vertical"> |
| | | <div class="base-grid"> |
| | | <a-form-item label="è¢ç " required><a-input v-model:value="code" size="large" :disabled="loading" placeholder="请è¾å
¥ææ«æè¢ç " /></a-form-item> |
| | | <a-form-item label="ç©æ" required> |
| | | <MdItemSelect v-model="itemId" :disabled="loading" size="large" placeholder="è¯·éæ©ç©æ" /> |
| | | </a-form-item> |
| | | <a-form-item label="çäº§æ¹æ¬¡"><a-input v-model:value="batchCode" size="large" :disabled="loading" placeholder="请è¾å
¥æ¹æ¬¡å·ï¼éå¡«ï¼" /></a-form-item> |
| | | <a-form-item label="å
¥åºåæ ¼åº" required class="full-width"> |
| | | <a-select |
| | | v-model:value="qualifiedWarehouseId" |
| | | size="large" |
| | | :disabled="loading" |
| | | placeholder="è¯·éæ©è´¨æ£åæ ¼åå
¥åºçåæ ¼åº" |
| | | :options="qualifiedWarehouses.map((item) => ({ label: `${item.name}ï¼${item.code}ï¼`, value: item.id }))" |
| | | /> |
| | | </a-form-item> |
| | | </div> |
| | | |
| | | <a-divider orientation="left">ææ ç»æ</a-divider> |
| | |
| | | <template #title><div class="card-title"><IconifyIcon icon="ep:data-analysis" />å¤å®è¯´æ</div></template> |
| | | <div class="result-hero" :class="overallPassed ? 'passed' : allEntered ? 'failed-result' : 'pending-result'"><IconifyIcon :icon="overallPassed ? 'ep:circle-check-filled' : allEntered ? 'ep:warning-filled' : 'ep:edit-pen'" /><strong>{{ overallPassed ? 'å½åç»æåæ ¼' : allEntered ? 'å½ååå¨ä¸åæ ¼é¡¹' : 'çå¾
å½å
¥ææ ' }}</strong><span>{{ allEntered ? '宿µå¼éå¤äºæå°å¼ä¸æå¤§å¼ä¹é´' : `å·²å½å
¥ ${enteredCount} / 4 项宿µå¼` }}</span></div> |
| | | <div class="rule-list"><div><IconifyIcon icon="ep:check" /><span>åé¡¹ææ å¿
é¡»å
¨é¨æäº¤</span></div><div><IconifyIcon icon="ep:check" /><span>è¾¹çå¼æåæ ¼å¤ç</span></div><div><IconifyIcon icon="ep:check" /><span>ä¸åæ ¼ç»æéä¿çåå </span></div><div><IconifyIcon icon="ep:check" /><span>è´¨æ£å䏿£éªæ¶é´ç±æå¡ç«¯è®°å½</span></div></div> |
| | | <a-alert message="åºåæµè½¬ç±å端å¤ç" description="åæ ¼åå°ä»æååºè½¬å
¥æ£å¼åºåï¼ä¸åæ ¼åå°ç´æ¥æ¸
餿ååºåå¹¶ä¿çä¸åæ ¼åå ãè´¨æ£ååæ£éªæ¶é´ç±æå¡ç«¯è®°å½ï¼æ¬é¡µé¢ä¸éæ©ä»åºãåºåºæåºä½ã" type="info" show-icon /> |
| | | <a-alert message="åºåæµè½¬ç±å端å¤ç" description="åæ ¼åå°ææéãå
¥åºåæ ¼åºã仿ååºåå
¥è¯¥åæ ¼åºï¼ä¸åæ ¼åå°ç´æ¥æ¸
餿ååºåå¹¶ä¿çä¸åæ ¼åå ãè´¨æ£ååæ£éªæ¶é´ç±æå¡ç«¯è®°å½ã" type="info" show-icon /> |
| | | <div v-if="submittedId" class="success-note"><IconifyIcon icon="ep:success-filled" /><span>æè¿æäº¤è´¨æ£ç¼å·ï¼<strong>{{ submittedId }}</strong></span></div> |
| | | </a-card> |
| | | </section> |
| | |
| | | <style scoped> |
| | | .quality-page { min-height: 100%; padding: 24px; background: #f4f7fb; color: #172033; } |
| | | .page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 22px; }.eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; }.page-header h1 { margin: 6px 0; font-size: 28px; }.page-header p { margin: 0; color: #718096; }.header-badge { display: inline-flex; align-items: center; gap: 8px; padding: 9px 13px; border: 1px solid #cfe0fb; border-radius: 7px; color: #1677ff; background: #f0f6ff; font-size: 13px; font-weight: 600; } |
| | | .summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 18px; }.summary-card { display: flex; align-items: flex-start; gap: 14px; padding: 17px 19px; border: 1px solid #e8edf5; border-radius: 10px; background: #fff; box-shadow: 0 3px 12px #243b5310; }.summary-card > .iconify { flex: 0 0 38px; width: 38px; height: 38px; padding: 9px; border-radius: 8px; font-size: 20px; }.summary-card.blue > .iconify { color: #1677ff; background: #eaf3ff; }.summary-card.green > .iconify { color: #17a673; background: #e6f8f0; }.summary-card.red > .iconify { color: #e05252; background: #fff0f0; }.summary-card span, .summary-card small { display: block; color: #7b8799; font-size: 12px; }.summary-card strong { display: block; margin: 4px 0; font-size: 23px; }.content-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(310px, .75fr); gap: 18px; }.form-card, .result-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 9px; font-weight: 700; }.card-title :deep(.iconify) { color: #1677ff; }.base-grid { display: grid; grid-template-columns: 1.3fr 1fr 1.2fr; gap: 16px; }.full-width { width: 100%; } |
| | | .summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 18px; }.summary-card { display: flex; align-items: flex-start; gap: 14px; padding: 17px 19px; border: 1px solid #e8edf5; border-radius: 10px; background: #fff; box-shadow: 0 3px 12px #243b5310; }.summary-card > .iconify { flex: 0 0 38px; width: 38px; height: 38px; padding: 9px; border-radius: 8px; font-size: 20px; }.summary-card.blue > .iconify { color: #1677ff; background: #eaf3ff; }.summary-card.green > .iconify { color: #17a673; background: #e6f8f0; }.summary-card.red > .iconify { color: #e05252; background: #fff0f0; }.summary-card span, .summary-card small { display: block; color: #7b8799; font-size: 12px; }.summary-card strong { display: block; margin: 4px 0; font-size: 23px; }.content-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(310px, .75fr); gap: 18px; }.form-card, .result-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 9px; font-weight: 700; }.card-title :deep(.iconify) { color: #1677ff; }.base-grid { display: grid; grid-template-columns: 1.3fr 1.2fr; gap: 16px; }.full-width { width: 100%; } |
| | | .indicator-list { display: flex; flex-direction: column; gap: 10px; }.indicator-row { display: flex; align-items: center; gap: 9px; padding: 11px 12px; border: 1px solid #e8edf5; border-radius: 7px; background: #fbfcfe; }.indicator-row.failed { border-color: #ffd6d6; background: #fffafa; }.indicator-name { display: flex; align-items: center; gap: 8px; min-width: 82px; }.indicator-name small { color: #8793a5; font-size: 12px; }.indicator-dot { width: 8px; height: 8px; border-radius: 50%; }.indicator-dot.pass { background: #20b878; }.indicator-dot.fail { background: #e05252; }.indicator-input { width: 105px; }.range-input { width: 88px; }.range-separator { margin-left: auto; color: #8793a5; font-size: 12px; }.reason-item { margin-top: 18px; }.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 5px; }.form-actions :deep(.iconify) { margin-right: 5px; } |
| | | .result-hero { display: flex; flex-direction: column; align-items: center; gap: 8px; margin: 8px 0 22px; padding: 25px 15px; border-radius: 8px; text-align: center; }.result-hero :deep(.iconify) { font-size: 35px; }.result-hero strong { font-size: 18px; }.result-hero span { color: #718096; font-size: 12px; }.result-hero.passed { color: #16835b; background: #effaf5; }.result-hero.failed-result { color: #c53e3e; background: #fff3f3; }.result-hero.pending-result { color: #1677ff; background: #f0f6ff; }.rule-list { display: flex; flex-direction: column; gap: 14px; margin-bottom: 22px; }.rule-list div { display: flex; align-items: center; gap: 9px; color: #59677b; font-size: 13px; }.rule-list :deep(.iconify) { color: #20b878; }.success-note { display: flex; align-items: center; gap: 8px; margin-top: 16px; padding: 11px 12px; border-radius: 7px; color: #16835b; background: #effaf5; font-size: 13px; } |
| | | @media (max-width: 1050px) { .content-grid { grid-template-columns: 1fr; }.base-grid { grid-template-columns: repeat(3, 1fr); } } |
| | | @media (max-width: 1050px) { .content-grid { grid-template-columns: 1fr; }.base-grid { grid-template-columns: repeat(2, 1fr); } } |
| | | @media (max-width: 760px) { .quality-page { padding: 14px; }.page-header { display: block; }.header-badge { margin-top: 14px; }.summary-grid, .base-grid { grid-template-columns: 1fr; }.indicator-row { flex-wrap: wrap; }.range-separator { margin-left: 0; }.indicator-input { flex: 1; min-width: 105px; }.range-input { flex: 1; min-width: 75px; } } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | |
| | | import type { TablePaginationConfig } from 'ant-design-vue'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { |
| | | getSeedQualityPage, |
| | | type SeedQualityRecord, |
| | | } from '#/api/mes/qc/seedQuality'; |
| | | |
| | | const records = ref<SeedQualityRecord[]>([]); |
| | | const loading = ref(false); |
| | | const page = ref(1); |
| | | const pageSize = ref(10); |
| | | |
| | | const code = ref(''); |
| | | const batchCode = ref(''); |
| | | const result = ref<number>(); |
| | | |
| | | const total = ref(0); |
| | | const passTotal = ref(0); |
| | | const failTotal = ref(0); |
| | | |
| | | const processingTotal = computed(() => |
| | | Math.max(total.value - passTotal.value - failTotal.value, 0), |
| | | ); |
| | | |
| | | const resultLabel = (value: number) => |
| | | value === 1 ? 'åæ ¼' : value === 2 ? 'ä¸åæ ¼' : 'å¤çä¸'; |
| | | const resultColor = (value: number) => |
| | | value === 1 ? '#16a34a' : value === 2 ? '#dc2626' : '#d97706'; |
| | | const resultBg = (value: number) => |
| | | value === 1 ? '#ecfdf3' : value === 2 ? '#fef2f2' : '#fffbeb'; |
| | | const formatDateTime = (value?: string) => |
| | | value ? new Date(value).toLocaleString() : 'â'; |
| | | |
| | | async function loadRecords() { |
| | | loading.value = true; |
| | | try { |
| | | const data = await getSeedQualityPage({ |
| | | pageNo: page.value, |
| | | pageSize: pageSize.value, |
| | | code: code.value.trim() || undefined, |
| | | batchCode: batchCode.value.trim() || undefined, |
| | | result: result.value, |
| | | }); |
| | | records.value = data.list; |
| | | total.value = data.total; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function loadStats() { |
| | | try { |
| | | const [all, pass, fail] = await Promise.all([ |
| | | getSeedQualityPage({ pageNo: 1, pageSize: 1 }), |
| | | getSeedQualityPage({ pageNo: 1, pageSize: 1, result: 1 }), |
| | | getSeedQualityPage({ pageNo: 1, pageSize: 1, result: 2 }), |
| | | ]); |
| | | total.value = all.total; |
| | | passTotal.value = pass.total; |
| | | failTotal.value = fail.total; |
| | | } catch { |
| | | // ç»è®¡å 载失败ä¸é»å¡å表 |
| | | } |
| | | } |
| | | |
| | | function searchRecords() { |
| | | page.value = 1; |
| | | void loadRecords(); |
| | | } |
| | | |
| | | function resetFilters() { |
| | | code.value = ''; |
| | | batchCode.value = ''; |
| | | result.value = undefined; |
| | | searchRecords(); |
| | | } |
| | | |
| | | function refreshAll() { |
| | | void Promise.all([loadRecords(), loadStats()]); |
| | | } |
| | | |
| | | function handleTableChange(pagination: TablePaginationConfig) { |
| | | page.value = pagination.current || 1; |
| | | pageSize.value = pagination.pageSize || 10; |
| | | void loadRecords(); |
| | | } |
| | | |
| | | onMounted(refreshAll); |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="record-page"> |
| | | <!-- 页头 --> |
| | | <header class="page-header"> |
| | | <div class="header-copy"> |
| | | <div class="eyebrow">MES / è´¨é管ç</div> |
| | | <h1>è´¨æ£è®°å½</h1> |
| | | <p>æè¢ç æ²æ·æ¯ä¸è¢ç§åçæ£éªç»è®ºï¼è¿½è¸ªåæ ¼ãä¸åæ ¼ä¸å¾
å¤çç»æ</p> |
| | | </div> |
| | | <div class="header-badge"> |
| | | <span class="badge-pulse" /> |
| | | 宿¶æ±æ» |
| | | </div> |
| | | </header> |
| | | |
| | | <!-- ç»æç»è®¡ --> |
| | | <section class="stat-grid" aria-label="è´¨æ£ç»ææ±æ»"> |
| | | <div class="stat-card total"> |
| | | <div class="stat-icon"><IconifyIcon icon="ep:document" /></div> |
| | | <div class="stat-main"><span>è®°å½æ»æ°</span><strong>{{ total.toLocaleString() }}</strong><small>æè¢ç 计</small></div> |
| | | </div> |
| | | <div class="stat-card pass"> |
| | | <div class="stat-icon"><IconifyIcon icon="ep:success-filled" /></div> |
| | | <div class="stat-main"><span>è´¨æ£åæ ¼</span><strong>{{ passTotal.toLocaleString() }}</strong><small>坿£å¸¸å
¥åæ ¼åº</small></div> |
| | | </div> |
| | | <div class="stat-card fail"> |
| | | <div class="stat-icon"><IconifyIcon icon="ep:warning-filled" /></div> |
| | | <div class="stat-main"><span>è´¨æ£ä¸åæ ¼</span><strong>{{ failTotal.toLocaleString() }}</strong><small>å·²æ è®°æ¥åº</small></div> |
| | | </div> |
| | | <div class="stat-card pending"> |
| | | <div class="stat-icon"><IconifyIcon icon="ep:clock" /></div> |
| | | <div class="stat-main"><span>å¤çä¸</span><strong>{{ processingTotal.toLocaleString() }}</strong><small>å¾
å®ç»è®º</small></div> |
| | | </div> |
| | | </section> |
| | | |
| | | <!-- è®°å½å表 --> |
| | | <section class="record-card"> |
| | | <div class="card-toolbar"> |
| | | <div class="card-title"> |
| | | <IconifyIcon icon="ep:list" /> |
| | | æç»å表 |
| | | <span class="count-chip">{{ total.toLocaleString() }} æ¡</span> |
| | | </div> |
| | | <div class="filters"> |
| | | <a-input v-model:value="code" allow-clear placeholder="è¢ç " @press-enter="searchRecords"> |
| | | <template #prefix><IconifyIcon icon="ep:box" /></template> |
| | | </a-input> |
| | | <a-input v-model:value="batchCode" allow-clear placeholder="çäº§æ¹æ¬¡" @press-enter="searchRecords"> |
| | | <template #prefix><IconifyIcon icon="ep:collection-tag" /></template> |
| | | </a-input> |
| | | <a-select |
| | | v-model:value="result" |
| | | allow-clear |
| | | placeholder="æ£éªç»æ" |
| | | :options="[ |
| | | { label: 'åæ ¼', value: 1 }, |
| | | { label: 'ä¸åæ ¼', value: 2 }, |
| | | { label: 'å¤çä¸', value: 0 }, |
| | | ]" |
| | | /> |
| | | <a-button type="primary" @click="searchRecords"><IconifyIcon icon="ep:search" />æ¥è¯¢</a-button> |
| | | <a-button @click="resetFilters"><IconifyIcon icon="ep:refresh-left" />éç½®</a-button> |
| | | <a-tooltip title="å·æ°æ°æ®"> |
| | | <a-button :loading="loading" aria-label="å·æ°æ°æ®" @click="refreshAll"><IconifyIcon icon="ep:refresh" /></a-button> |
| | | </a-tooltip> |
| | | </div> |
| | | </div> |
| | | |
| | | <a-table |
| | | :data-source="records" |
| | | :loading="loading" |
| | | :scroll="{ x: 1120 }" |
| | | :pagination="{ |
| | | current: page, |
| | | pageSize, |
| | | total, |
| | | showSizeChanger: true, |
| | | showTotal: (t: number) => `å
± ${t} æ¡`, |
| | | }" |
| | | row-key="id" |
| | | size="middle" |
| | | @change="handleTableChange" |
| | | > |
| | | <template #emptyText> |
| | | <div class="empty-state"> |
| | | <IconifyIcon icon="ep:data-board" /> |
| | | <p>ææ è´¨æ£è®°å½</p> |
| | | </div> |
| | | </template> |
| | | <a-table-column key="id" title="è´¨æ£ç¼å·" data-index="id" width="100" align="right"> |
| | | <template #default="{ record }"><span class="mono muted">{{ record.id }}</span></template> |
| | | </a-table-column> |
| | | <a-table-column key="code" title="è¢ç " data-index="code" min-width="220"> |
| | | <template #default="{ record }"><span class="mono strong">{{ record.code }}</span></template> |
| | | </a-table-column> |
| | | <a-table-column key="batchCode" title="çäº§æ¹æ¬¡" data-index="batchCode" min-width="190"> |
| | | <template #default="{ record }">{{ record.batchCode || 'â' }}</template> |
| | | </a-table-column> |
| | | <a-table-column key="result" title="æ£éªç»æ" width="130"> |
| | | <template #default="{ record }"> |
| | | <span class="result-tag" :style="{ color: resultColor(record.result), background: resultBg(record.result) }"> |
| | | <i class="result-dot" :style="{ background: resultColor(record.result) }" /> |
| | | {{ resultLabel(record.result) }} |
| | | </span> |
| | | </template> |
| | | </a-table-column> |
| | | <a-table-column key="reason" title="ä¸åæ ¼åå " data-index="reason" min-width="220" ellipsis> |
| | | <template #default="{ record }"> |
| | | <span v-if="record.reason" class="reason-text">{{ record.reason }}</span> |
| | | <span v-else class="muted">â</span> |
| | | </template> |
| | | </a-table-column> |
| | | <a-table-column key="inspectedAt" title="æ£éªæ¶é´" width="190"> |
| | | <template #default="{ record }"><span class="mono muted">{{ formatDateTime(record.inspectedAt) }}</span></template> |
| | | </a-table-column> |
| | | <a-table-column key="createTime" title="æäº¤æ¶é´" width="190"> |
| | | <template #default="{ record }"><span class="mono muted">{{ formatDateTime(record.createTime) }}</span></template> |
| | | </a-table-column> |
| | | </a-table> |
| | | </section> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .record-page { min-height: 100%; padding: 20px 24px 32px; background: #f4f7fb; color: #172033; } |
| | | |
| | | /* ===== 页头 ===== */ |
| | | .page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; } |
| | | .header-copy .eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; } |
| | | .header-copy h1 { margin: 6px 0; font-size: 26px; line-height: 1.2; } |
| | | .header-copy p { margin: 0; color: #718096; } |
| | | .header-badge { display: inline-flex; align-items: center; gap: 8px; padding: 9px 14px; border: 1px solid #cfe0fb; border-radius: 999px; color: #1677ff; background: #f0f6ff; font-size: 13px; font-weight: 600; } |
| | | .badge-pulse { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 0 0 rgba(34, 197, 94, .45); animation: pulse 2s infinite; } |
| | | @keyframes pulse { 70% { box-shadow: 0 0 0 7px rgba(34, 197, 94, 0); } 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } } |
| | | |
| | | /* ===== ç»æç»è®¡ ===== */ |
| | | .stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; } |
| | | .stat-card { display: flex; align-items: center; gap: 13px; padding: 15px 17px; border: 1px solid #e8edf5; border-radius: 12px; background: #fff; box-shadow: 0 3px 12px #243b5310; } |
| | | .stat-icon { flex: 0 0 40px; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; border-radius: 10px; font-size: 21px; } |
| | | .stat-icon :deep(.iconify) { font-size: 21px; } |
| | | .stat-main span, .stat-main small { display: block; color: #7b8799; font-size: 12px; } |
| | | .stat-main small { margin-top: 2px; } |
| | | .stat-main strong { display: block; margin: 3px 0 0; font-size: 23px; line-height: 1; font-variant-numeric: tabular-nums; } |
| | | .stat-card.total .stat-icon { color: #1677ff; background: #eaf3ff; } |
| | | .stat-card.total .stat-main strong { color: #1677ff; } |
| | | .stat-card.pass .stat-icon { color: #16a34a; background: #ecfdf3; } |
| | | .stat-card.pass .stat-main strong { color: #16a34a; } |
| | | .stat-card.fail .stat-icon { color: #dc2626; background: #fef2f2; } |
| | | .stat-card.fail .stat-main strong { color: #dc2626; } |
| | | .stat-card.pending .stat-icon { color: #d97706; background: #fffbeb; } |
| | | .stat-card.pending .stat-main strong { color: #d97706; } |
| | | |
| | | /* ===== æç»å¡ç ===== */ |
| | | .record-card { padding: 6px 16px 16px; border: 1px solid #e8edf5; border-radius: 12px; background: #fff; box-shadow: 0 3px 12px #243b5310; } |
| | | .card-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 13px 0 15px; flex-wrap: wrap; border-bottom: 1px dashed #eef1f6; } |
| | | .card-title { display: inline-flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; } |
| | | .card-title :deep(.iconify) { color: #1677ff; } |
| | | .count-chip { padding: 1px 9px; border-radius: 999px; background: #f0f6ff; color: #1677ff; font-size: 12px; font-weight: 600; } |
| | | .filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| | | .filters .ant-input { width: 150px; } |
| | | .filters .ant-select { width: 128px; } |
| | | .filters .ant-btn { display: inline-flex; align-items: center; gap: 5px; } |
| | | .filters :deep(.ant-btn .iconify) { font-size: 14px; } |
| | | |
| | | /* ===== åå
æ ¼ ===== */ |
| | | .strong { font-weight: 600; color: #172033; } |
| | | .muted { color: #8793a5; } |
| | | .mono { font-variant-numeric: tabular-nums; } |
| | | .result-tag { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; line-height: 20px; } |
| | | .result-dot { width: 6px; height: 6px; border-radius: 50%; } |
| | | .reason-text { color: #59677b; } |
| | | .empty-state { padding: 34px 0; text-align: center; color: #b6c0cf; } |
| | | .empty-state :deep(.iconify) { font-size: 42px; color: #d5dce6; } |
| | | .empty-state p { margin: 10px 0 0; } |
| | | |
| | | /* ç¶ææç¥è¡ï¼ä¸åæ ¼è¡è½»é«äº® */ |
| | | :deep(.ant-table-row) { transition: background-color .18s ease; } |
| | | :deep(.ant-table-row:hover) { background: #f7faff !important; } |
| | | |
| | | /* ===== ååºå¼ ===== */ |
| | | @media (max-width: 1100px) { .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } |
| | | @media (max-width: 760px) { |
| | | .record-page { padding: 14px; } |
| | | .page-header { display: block; } |
| | | .header-badge { margin-top: 12px; } |
| | | .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } |
| | | .card-toolbar { align-items: flex-start; flex-direction: column; } |
| | | .filters .ant-input { width: 100%; flex: 1; } |
| | | } |
| | | </style> |
| | |
| | | export { default as WmMaterialStockSelect } from './select.vue'; |
| | | export { default as WmStockAdjustModal } from './stock-adjust-modal.vue'; |
| | | export { default as WmStockMoveModal } from './move-modal.vue'; |
| | | export { default as WmStockBagModal } from './stock-bag-modal.vue'; |
| | |
| | | mdmItemId?: number; |
| | | virtualFilter?: VirtualFilter; |
| | | warehouseId?: number; |
| | | warehouseType?: number; |
| | | }>(), |
| | | { |
| | | batchId: undefined, |
| | |
| | | mdmItemId: undefined, |
| | | virtualFilter: 'exclude', |
| | | warehouseId: undefined, |
| | | warehouseType: undefined, |
| | | }, |
| | | ); |
| | | |
| | |
| | | itemTypeId: searchItemTypeId.value, |
| | | itemId: props.mdmItemId, |
| | | warehouseId: formValues.warehouseId ?? props.warehouseId, |
| | | warehouseType: props.warehouseType, |
| | | virtualFilter: |
| | | props.virtualFilter === 'all' ? undefined : props.virtualFilter, |
| | | }); |
| | |
| | | placeholder?: string; |
| | | virtualFilter?: 'all' | 'exclude' | 'only'; |
| | | warehouseId?: number; |
| | | warehouseType?: number; |
| | | }>(), |
| | | { |
| | | allowClear: true, |
| | |
| | | placeholder: 'è¯·éæ©åºå', |
| | | virtualFilter: 'exclude', |
| | | warehouseId: undefined, |
| | | warehouseType: undefined, |
| | | }, |
| | | ); |
| | | |
| | |
| | | :mdm-item-id="mdmItemId" |
| | | :virtual-filter="virtualFilter" |
| | | :warehouse-id="warehouseId" |
| | | :warehouse-type="warehouseType" |
| | | @selected="handleSelected" |
| | | /> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock'; |
| | | |
| | | import { ref } from 'vue'; |
| | | |
| | | import { useVbenModal } from '@vben/common-ui'; |
| | | import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { getMaterialStockBagDetails } from '#/api/mes/wm/materialstock'; |
| | | |
| | | const stockId = ref(0); |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | gridOptions: { |
| | | columns: [ |
| | | { field: 'bagCode', title: 'è¢ç ', minWidth: 180 }, |
| | | { field: 'batchCode', title: 'æ¹æ¬¡å·', minWidth: 150 }, |
| | | { |
| | | field: 'stockState', |
| | | title: 'åºåç¶æ', |
| | | width: 110, |
| | | cellRender: { |
| | | name: 'CellTag', |
| | | props: { |
| | | options: [ |
| | | { label: 'æååº', value: 10, color: 'orange' }, |
| | | { label: 'åæ ¼åº', value: 20, color: 'green' }, |
| | | { label: 'å·²åºåº', value: 30, color: 'default' }, |
| | | ], |
| | | }, |
| | | }, |
| | | }, |
| | | { field: 'quantity', title: 'æ°é', width: 80 }, |
| | | { field: 'inboundTime', title: 'å
¥åºæ¶é´', width: 180 }, |
| | | ], |
| | | proxyConfig: { |
| | | ajax: { |
| | | query: async ({ page }) => getMaterialStockBagDetails({ |
| | | materialStockId: stockId.value, |
| | | pageNo: page.currentPage, |
| | | pageSize: page.pageSize, |
| | | }), |
| | | }, |
| | | }, |
| | | rowConfig: { keyField: 'id' }, |
| | | }, |
| | | }); |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | onOpenChange(isOpen) { |
| | | if (!isOpen) return; |
| | | const data = modalApi.getData<{ materialStockId?: number }>(); |
| | | stockId.value = data?.materialStockId ?? 0; |
| | | gridApi.query(); |
| | | }, |
| | | }); |
| | | |
| | | defineExpose({ open: (materialStockId: number) => modalApi.setData({ materialStockId }).open() }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Modal title="åºåè¢ç æç»" class="w-[900px]"> |
| | | <Grid /> |
| | | </Modal> |
| | | </template> |
| | |
| | | slots: { default: 'batchCode' }, |
| | | }, |
| | | { |
| | | field: 'bagCount', |
| | | title: 'è¢ç æ°', |
| | | width: 90, |
| | | }, |
| | | { |
| | | field: 'warehouseName', |
| | | title: 'ä»åº', |
| | | minWidth: 100, |
| | |
| | | }, |
| | | }, |
| | | }, |
| | | ...(onMove |
| | | ? [ |
| | | { |
| | | title: 'æä½', |
| | | width: 80, |
| | | fixed: 'right' as const, |
| | | slots: { default: 'actions' } as const, |
| | | }, |
| | | ] |
| | | : []), |
| | | { |
| | | title: 'æä½', |
| | | width: onMove ? 150 : 90, |
| | | fixed: 'right' as const, |
| | | slots: { default: 'actions' } as const, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | |
| | | } from '#/api/mes/wm/materialstock'; |
| | | import { $t } from '#/locales'; |
| | | import { MdItemTypeTree } from '#/views/mes/md/item/type/components'; |
| | | import { WmBatchDetail } from '#/views/wls/batch/components'; |
| | | import QrTraceModal from '#/views/mes/pro/components/qr-trace-modal.vue'; |
| | | import AreaForm from '#/views/wls/warehouse/area/modules/form.vue'; |
| | | import { WmStockAdjustModal, WmStockMoveModal } from './components'; |
| | | import { |
| | | WmStockAdjustModal, |
| | | WmStockBagModal, |
| | | WmStockMoveModal, |
| | | } from './components'; |
| | | |
| | | import { useGridColumns, useGridFormSchema } from './data'; |
| | | |
| | |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | const batchDetailRef = ref<InstanceType<typeof WmBatchDetail>>(); |
| | | const [QrTraceQueryModal, qrTraceQueryModalApi] = useVbenModal({ |
| | | connectedComponent: QrTraceModal, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | const [StockBagModal, stockBagModalApi] = useVbenModal({ |
| | | connectedComponent: WmStockBagModal, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | function handleOpenStockBags(row: MesWmMaterialStockApi.MaterialStock) { |
| | | if (!row.id) return; |
| | | stockBagModalApi.setData({ materialStockId: row.id }).open(); |
| | | } |
| | | |
| | | /** å·æ°è¡¨æ ¼ */ |
| | | function handleRefresh() { |
| | |
| | | gridApi.query(); |
| | | } |
| | | |
| | | /** æå¼æ¹æ¬¡è¯¦æ
å¼¹çª */ |
| | | /** æå¼æ¹æ¬¡è¿½æº¯å¼¹çª */ |
| | | function handleOpenBatchDetail(row: MesWmMaterialStockApi.MaterialStock) { |
| | | if (!row.batchId) { |
| | | if (!row.batchCode) { |
| | | return; |
| | | } |
| | | batchDetailRef.value?.open(row.batchId); |
| | | qrTraceQueryModalApi.setData({ code: row.batchCode }).open(); |
| | | } |
| | | |
| | | /** å¤çå»ç»ç¶æåæ¢ */ |
| | |
| | | </script> |
| | | |
| | | <template> |
| | | <Page auto-content-height><AreaModal /> |
| | | <WmBatchDetail ref="batchDetailRef" /> |
| | | <Page auto-content-height> |
| | | <AreaModal /> |
| | | <QrTraceQueryModal /> |
| | | <StockBagModal /> |
| | | <StockAdjustModal @success="handleStockAdjustSuccess" /> |
| | | <StockMoveModal @success="handleStockMoveSuccess" /> |
| | | |
| | |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'æ¥çè¢ç ', |
| | | type: 'link', |
| | | ifShow: row.bagDetailSupported, |
| | | onClick: handleOpenStockBags.bind(null, row), |
| | | }, |
| | | { |
| | | label: 'ç§»åº', |
| | | type: 'link', |
| | | auth: ['mes:wm-material-stock:move'], |
| | |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesWmSalesNoticeStatusEnum, |
| | | MesWmWarehouseTypeEnum, |
| | | } from '@vben/constants'; |
| | | import { getDictOptions } from '@vben/hooks'; |
| | | |
| | |
| | | import { z } from '#/adapter/form'; |
| | | import CrmCustomerSelect from '#/components/crm-customer-select.vue'; |
| | | import { generateAutoCode } from '#/api/mes/md/autocode/record'; |
| | | import { ErpSaleOrderSelect } from '#/views/erp/sale/order/components'; |
| | | import { getRangePickerDefaultProps } from '#/utils'; |
| | | import { MdItemSelect } from '#/views/mes/md/item/components'; |
| | | import { WmMaterialStockSelect } from '#/views/wls/materialstock/components'; |
| | |
| | | component: markRaw(WmSalesNoticeSelect), |
| | | componentProps: { |
| | | disabled: isHeaderReadonly(formType), |
| | | // éæ©åè´§éç¥ååï¼èªå¨åå¡«éå®è®¢åå·ã客æ·ãæ¶è´§äººä¿¡æ¯ |
| | | // éæ©åè´§éç¥ååï¼èªå¨å填客æ·ãæ¶è´§äººä¿¡æ¯ |
| | | onChange: async (notice?: MesWmSalesNoticeApi.SalesNotice) => { |
| | | await formApi?.setValues({ |
| | | clientId: notice?.clientId, |
| | | contactAddress: notice?.recipientAddress, |
| | | contactName: notice?.recipientName, |
| | | contactTelephone: notice?.recipientTelephone, |
| | | salesOrderCode: notice?.salesOrderCode, |
| | | }); |
| | | }, |
| | | status: MesWmSalesNoticeStatusEnum.APPROVED, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'saleOrderId', |
| | | label: 'éå®è®¢åç¼å·', |
| | | component: markRaw(ErpSaleOrderSelect), |
| | | componentProps: { |
| | | disabled: isHeaderReadonly(formType), |
| | | placeholder: 'è¯·éæ©éå®è®¢å', |
| | | onChange: (item: any) => { |
| | | if (formApi) { |
| | | formApi.setFieldValue('salesOrderCode', item?.no ?? ''); |
| | | if (item?.customerId) { |
| | | formApi.setFieldValue('clientId', item.customerId); |
| | | } |
| | | } |
| | | }, |
| | | }, |
| | | rules: 'selectRequired', |
| | | dependencies: { |
| | | triggerFields: ['salesOrderCode'], |
| | | if: () => formType === 'create', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'salesOrderCode', |
| | | label: 'éå®è®¢åç¼å·', |
| | | component: 'Input', |
| | | componentProps: { |
| | | disabled: true, |
| | | placeholder: 'éå®è®¢åç¼å·', |
| | | }, |
| | | dependencies: { |
| | | triggerFields: [''], |
| | | if: () => formType !== 'create', |
| | | }, |
| | | }, |
| | | { |
| | |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'salesOrderCode', |
| | | label: 'éå®è®¢åç¼å·', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥éå®è®¢åç¼å·', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'clientId', |
| | | label: '客æ·', |
| | | component: markRaw(CrmCustomerSelect), |
| | |
| | | { |
| | | field: 'noticeCode', |
| | | title: 'åè´§éç¥åå·', |
| | | minWidth: 160, |
| | | }, |
| | | { |
| | | field: 'salesOrderCode', |
| | | title: 'éå®è®¢åç¼å·', |
| | | minWidth: 160, |
| | | }, |
| | | { |
| | |
| | | label: 'éæ©åºå', |
| | | component: markRaw(WmMaterialStockSelect), |
| | | componentProps: { |
| | | // åªä»åæ ¼ä»åºï¼type=20ï¼éæ©å¯æ£è´§åºå |
| | | warehouseType: MesWmWarehouseTypeEnum.QUALIFIED, |
| | | // éæ©åºåè®°å½åï¼èªå¨åå¡«ä»åº/åºåº/åºä½/æ¹æ¬¡/æ°é |
| | | onChange: async (stock?: MesWmMaterialStockApi.MaterialStock) => { |
| | | await formApi?.setValues({ |
| | |
| | | import type { ErpSaleOrderApi } from '#/api/erp/sale/order'; |
| | | import type { VbenFormApi, VbenFormSchema } from '#/adapter/form'; |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { MesWmReturnSalesApi } from '#/api/mes/wm/returnsales'; |
| | |
| | | import { z } from '#/adapter/form'; |
| | | import CrmCustomerSelect from '#/components/crm-customer-select.vue'; |
| | | import { generateAutoCode } from '#/api/mes/md/autocode/record'; |
| | | import { ErpSaleOrderSelect } from '#/views/erp/sale/order/components'; |
| | | import { MdItemSelect } from '#/views/mes/md/item/components'; |
| | | import { WmBatchSelect } from '#/views/wls/batch/components'; |
| | | import { |
| | |
| | | export function useFormSchema( |
| | | formType: FormType, |
| | | formApi?: VbenFormApi, |
| | | onOrderChange?: (item: Record<string, unknown> | undefined) => void, |
| | | ): VbenFormSchema[] { |
| | | return [ |
| | | { |
| | |
| | | placeholder: '请è¾å
¥éè´§ååç§°', |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'salesOrderCode', |
| | | component: 'Input', |
| | | dependencies: { |
| | | triggerFields: [''], |
| | | show: () => false, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'saleOrderId', |
| | | label: 'éå®è®¢åå·', |
| | | component: markRaw(ErpSaleOrderSelect), |
| | | componentProps: { |
| | | outStatus: 3, |
| | | placeholder: 'è¯·éæ©éå®è®¢å', |
| | | onChange: (item: any) => { |
| | | if (formApi) { |
| | | formApi.setFieldValue('salesOrderCode', item?.no ?? ''); |
| | | if (item?.customerId) { |
| | | formApi.setFieldValue('clientId', item.customerId); |
| | | } |
| | | } |
| | | onOrderChange?.(item); |
| | | }, |
| | | }, |
| | | rules: 'selectRequired', |
| | | }, |
| | | { |
| | | fieldName: 'clientId', |
| | |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'salesOrderCode', |
| | | label: 'éå®è®¢åå·', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥éå®è®¢åå·', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'clientId', |
| | | label: '客æ·', |
| | | component: markRaw(CrmCustomerSelect), |
| | |
| | | field: 'name', |
| | | title: 'éè´§ååç§°', |
| | | minWidth: 150, |
| | | }, |
| | | { |
| | | field: 'salesOrderCode', |
| | | title: 'éå®è®¢åå·', |
| | | minWidth: 140, |
| | | }, |
| | | { |
| | | field: 'clientCode', |
| | |
| | | } |
| | | |
| | | /** éè´§åè¡æ°å¢/ä¿®æ¹ç表å */ |
| | | export function useLineFormSchema( |
| | | clientId?: number, |
| | | salesOrderCode?: string, |
| | | items?: ErpSaleOrderApi.SaleOrderItem[], |
| | | onItemChange?: (item: ErpSaleOrderApi.SaleOrderItem | undefined) => void, |
| | | ): VbenFormSchema[] { |
| | | const hasItems = items && items.length > 0; |
| | | export function useLineFormSchema(clientId?: number): VbenFormSchema[] { |
| | | return [ |
| | | { |
| | | fieldName: 'itemId', |
| | | label: 'ç©æ', |
| | | component: hasItems ? 'Select' : markRaw(MdItemSelect), |
| | | componentProps: hasItems |
| | | ? { |
| | | placeholder: 'è¯·éæ©ç©æ', |
| | | options: items.map((item) => ({ |
| | | label: `${item.productName || ''}ï¼${item.productBarCode || ''}ï¼`, |
| | | value: item.productId, |
| | | })), |
| | | onChange: (value: number | undefined) => { |
| | | const selected = items.find((i) => i.productId === value); |
| | | onItemChange?.(selected); |
| | | }, |
| | | } |
| | | : { |
| | | placeholder: 'è¯·éæ©ç©æ', |
| | | productIds: [], |
| | | }, |
| | | component: markRaw(MdItemSelect), |
| | | componentProps: { |
| | | placeholder: 'è¯·éæ©ç©æ', |
| | | }, |
| | | rules: 'selectRequired', |
| | | }, |
| | | { |
| | |
| | | placeholder: '请è¾å
¥éè´§æ°é', |
| | | precision: 2, |
| | | }, |
| | | dependencies: { |
| | | triggerFields: ['itemId'], |
| | | componentProps: (values) => { |
| | | const selected = items?.find((i) => i.productId === values.itemId); |
| | | return { |
| | | class: '!w-full', |
| | | max: selected?.count ?? undefined, |
| | | min: 0, |
| | | placeholder: '请è¾å
¥éè´§æ°é', |
| | | precision: 2, |
| | | }; |
| | | }, |
| | | rules: (values) => { |
| | | const selected = items?.find((i) => i.productId === values.itemId); |
| | | const max = selected?.count; |
| | | if (max !== undefined && max !== null) { |
| | | return z |
| | | .number({ message: '请è¾å
¥éè´§æ°é' }) |
| | | .min(0) |
| | | .max(max, `å¯éè´§æ°éä¸è½è¶
è¿ ${max}`); |
| | | } |
| | | return z.number({ message: '请è¾å
¥éè´§æ°é' }).min(0); |
| | | }, |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | |
| | | clientId, |
| | | disabled: true, |
| | | placeholder: 'è¯·éæ©æ¹æ¬¡', |
| | | salesOrderCode, |
| | | }, |
| | | dependencies: { |
| | | triggerFields: ['itemId'], |
| | |
| | | disabled: true, |
| | | itemId: values.itemId, |
| | | placeholder: 'è¯·éæ©æ¹æ¬¡', |
| | | salesOrderCode, |
| | | }), |
| | | }, |
| | | }, |
| | |
| | | submitReturnSales, |
| | | updateReturnSales, |
| | | } from '#/api/mes/wm/returnsales'; |
| | | import type { ErpSaleOrderApi } from '#/api/erp/sale/order'; |
| | | |
| | | import { getSaleOrder, getSaleOrderPage } from '#/api/erp/sale/order'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | import { useFormSchema } from '../data'; |
| | |
| | | const formType = ref<FormType>('create'); |
| | | const formData = ref<MesWmReturnSalesApi.ReturnSales>(); |
| | | const originalSnapshot = ref(''); // 表ååå§æ°æ®å¿«ç
§ï¼ç¨äºæäº¤æ¶è·³è¿æªåæ´çä¿åè¯·æ± |
| | | const saleOrderItems = ref<ErpSaleOrderApi.SaleOrderItem[]>([]); // å½åéå®è®¢åçç©æè¡ |
| | | const isEditable = computed(() => |
| | | // æ¯å¦ä¸ºç¼è¾æ¨¡å¼ï¼å¯ä¿åï¼ |
| | | ['create', 'update'].includes(formType.value), |
| | |
| | | // å è½½æ°æ® |
| | | const data = modalApi.getData<{ formType: FormType; id?: number }>(); |
| | | formType.value = data.formType; |
| | | formApi.setState({ |
| | | schema: useFormSchema(formType.value, formApi, (item) => { |
| | | if (item?.id) { |
| | | getSaleOrder(item.id as number).then((order) => { |
| | | saleOrderItems.value = (order as ErpSaleOrderApi.SaleOrder)?.items || []; |
| | | }); |
| | | } else { |
| | | saleOrderItems.value = []; |
| | | } |
| | | }), |
| | | }); |
| | | formApi.setState({ schema: useFormSchema(formType.value, formApi) }); |
| | | formApi.setDisabled(!isEditable.value); |
| | | modalApi.setState({ showConfirmButton: isEditable.value }); |
| | | if (data?.id) { |
| | | modalApi.lock(); |
| | | try { |
| | | formData.value = await getReturnSales(data.id); |
| | | // éè¿éå®è®¢åå·åæ¥è®¢åIDï¼ç¡®ä¿ ErpSaleOrderSelect è½æ£ç¡®åæ¾ |
| | | if (formData.value?.salesOrderCode && !formData.value.saleOrderId) { |
| | | const orderPage = await getSaleOrderPage({ |
| | | pageNo: 1, |
| | | pageSize: 1, |
| | | no: formData.value.salesOrderCode, |
| | | } as Record<string, unknown> as never); |
| | | const list = (orderPage as Record<string, unknown>)?.list as Array<Record<string, unknown>> | undefined; |
| | | if (list?.length) { |
| | | const orderId = list[0].id as number; |
| | | (formData.value as Record<string, unknown>).saleOrderId = orderId; |
| | | const order = await getSaleOrder(orderId); |
| | | saleOrderItems.value = order?.items || []; |
| | | } |
| | | } |
| | | // è®¾ç½®å° values |
| | | await formApi.setValues(formData.value); |
| | | } finally { |
| | |
| | | :client-id="formData.clientId" |
| | | :form-type="formType" |
| | | :return-id="formData.id" |
| | | :sale-order-items="saleOrderItems" |
| | | :sales-order-code="formData.salesOrderCode" |
| | | /> |
| | | </div> |
| | | </template> |
| | |
| | | <script lang="ts" setup> |
| | | import type { ErpSaleOrderApi } from '#/api/erp/sale/order'; |
| | | import type { MesWmReturnSalesLineApi } from '#/api/mes/wm/returnsales/line'; |
| | | |
| | | import { computed, ref } from 'vue'; |
| | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | | import { useVbenForm } from '#/adapter/form'; |
| | | import { getBatchPage } from '#/api/mes/wm/batch'; |
| | | import { |
| | | createReturnSalesLine, |
| | | getReturnSalesLine, |
| | |
| | | const emit = defineEmits(['success']); |
| | | const formData = ref<MesWmReturnSalesLineApi.ReturnSalesLine>(); |
| | | const returnId = ref<number>(); // æå±éè´§åç¼å· |
| | | const maxQuantity = ref<number>(); // å½åéä¸ç©æå¯éè´§çæå¤§æ°é |
| | | |
| | | const getTitle = computed(() => { |
| | | return formData.value?.id |
| | |
| | | const data = modalApi.getData<{ |
| | | clientId?: number; |
| | | id?: number; |
| | | items?: ErpSaleOrderApi.SaleOrderItem[]; |
| | | returnId: number; |
| | | salesOrderCode?: string; |
| | | }>(); |
| | | returnId.value = data.returnId; |
| | | maxQuantity.value = undefined; |
| | | formApi.setState({ |
| | | schema: useLineFormSchema( |
| | | data.clientId, |
| | | data.salesOrderCode, |
| | | data.items || [], |
| | | async (item) => { |
| | | maxQuantity.value = item?.count; |
| | | // éç½®æ¹æ¬¡ |
| | | await formApi.setFieldValue('batchId', undefined); |
| | | if (item?.batchCode) { |
| | | try { |
| | | const batchPage = await getBatchPage({ |
| | | code: item.batchCode, |
| | | pageNo: 1, |
| | | pageSize: 1, |
| | | }); |
| | | const batchList = batchPage?.list; |
| | | if (batchList?.length) { |
| | | await formApi.setFieldValue('batchId', batchList[0].id); |
| | | } |
| | | } catch { |
| | | // æ¥è¯¢å¤±è´¥æ¶å¿½ç¥ |
| | | } |
| | | } |
| | | }, |
| | | ), |
| | | }); |
| | | formApi.setState({ schema: useLineFormSchema(data.clientId) }); |
| | | if (!data.id) { |
| | | return; |
| | | } |
| | |
| | | <template> |
| | | <Modal :title="getTitle" class="w-3/5"> |
| | | <Form class="mx-4" /> |
| | | <div |
| | | v-if="maxQuantity !== undefined" |
| | | class="mx-4 mt-2 text-sm text-blue-600" |
| | | > |
| | | å½åç©æå¯éè´§æ°éï¼<span class="font-bold">{{ maxQuantity }}</span> |
| | | </div> |
| | | </Modal> |
| | | </template> |
| | |
| | | <script lang="ts" setup> |
| | | import type { FormType } from '../data'; |
| | | |
| | | import type { ErpSaleOrderApi } from '#/api/erp/sale/order'; |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { MesWmReturnSalesDetailApi } from '#/api/mes/wm/returnsales/detail'; |
| | | import type { MesWmReturnSalesLineApi } from '#/api/mes/wm/returnsales/line'; |
| | |
| | | clientId?: number; |
| | | formType: FormType; |
| | | returnId: number; |
| | | saleOrderItems?: ErpSaleOrderApi.SaleOrderItem[]; |
| | | salesOrderCode?: string; |
| | | }>(); |
| | | |
| | | const isEditable = computed(() => |
| | |
| | | lineFormModalApi |
| | | .setData({ |
| | | clientId: props.clientId, |
| | | items: props.saleOrderItems, |
| | | returnId: props.returnId, |
| | | salesOrderCode: props.salesOrderCode, |
| | | }) |
| | | .open(); |
| | | } |
| | |
| | | .setData({ |
| | | clientId: props.clientId, |
| | | id: row.id, |
| | | items: props.saleOrderItems, |
| | | returnId: props.returnId, |
| | | salesOrderCode: props.salesOrderCode, |
| | | }) |
| | | .open(); |
| | | } |
| | |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'saleOrderCode', |
| | | label: 'éå®è®¢åç¼å·', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥éå®è®¢åç¼å·', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'clientId', |
| | | label: '客æ·', |
| | | component: markRaw(CrmCustomerSelect), |
| | |
| | | field: 'name', |
| | | title: 'éç¥ååç§°', |
| | | minWidth: 150, |
| | | }, |
| | | { |
| | | field: 'saleOrderCode', |
| | | title: 'éå®è®¢åç¼å·', |
| | | minWidth: 140, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | |
| | | <div>ç¼å·ï¼{{ selectedItem.code || '-' }}</div> |
| | | <div>åç§°ï¼{{ selectedItem.name || '-' }}</div> |
| | | <div>客æ·ï¼{{ selectedItem.clientName || '-' }}</div> |
| | | <div>éå®è®¢åï¼{{ selectedItem.saleOrderCode || '-' }}</div> |
| | | </div> |
| | | </template> |
| | | <Input |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesWmWarehouseTypeEnum, |
| | | } from '@vben/constants'; |
| | | |
| | | import { Button } from 'ant-design-vue'; |
| | |
| | | import { z } from '#/adapter/form'; |
| | | import { generateAutoCode } from '#/api/mes/md/autocode/record'; |
| | | import CrmCustomerSelect from '#/components/crm-customer-select.vue'; |
| | | import { ErpSaleOrderSelect } from '#/views/erp/sale/order/components'; |
| | | import { MdItemSelect } from '#/views/mes/md/item/components'; |
| | | import { |
| | | getStockSummaryByBatch, |
| | | getStockSummaryByItem, |
| | | type MesWmMaterialStockApi, |
| | | } from '#/api/mes/wm/materialstock'; |
| | | |
| | | /** 表åç±»å */ |
| | | export type FormType = 'create' | 'detail' | 'update'; |
| | |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'saleOrderId', |
| | | label: 'éå®è®¢å', |
| | | component: markRaw(ErpSaleOrderSelect), |
| | | componentProps: { |
| | | placeholder: 'è¯·éæ©éå®è®¢å', |
| | | // éæ©éå®è®¢ååï¼èªå¨å填客æ·ä¿¡æ¯ |
| | | onChange: (item: any) => { |
| | | if (item?.customerId && formApi) { |
| | | formApi.setFieldValue('clientId', item.customerId); |
| | | } |
| | | }, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'clientId', |
| | | label: '客æ·', |
| | | component: markRaw(CrmCustomerSelect), |
| | | componentProps: { |
| | | placeholder: 'è¯·éæ©å®¢æ·', |
| | | disabled: true, |
| | | }, |
| | | rules: 'selectRequired', |
| | | }, |
| | |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'saleOrderCode', |
| | | label: 'éå®è®¢åç¼å·', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥éå®è®¢åç¼å·', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'clientId', |
| | | label: '客æ·', |
| | | component: markRaw(CrmCustomerSelect), |
| | |
| | | field: 'name', |
| | | title: 'éç¥ååç§°', |
| | | minWidth: 150, |
| | | }, |
| | | { |
| | | field: 'saleOrderCode', |
| | | title: 'éå®è®¢åç¼å·', |
| | | minWidth: 140, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | |
| | | ]; |
| | | } |
| | | |
| | | /** éç¥åè¡æ°å¢/ä¿®æ¹ç表å */ |
| | | /** æ£å¼åºï¼åæ ¼åºåºåï¼stockState = 20ï¼æåºåçç©æé项 */ |
| | | export async function loadQualifiedStockItemOptions(): Promise< |
| | | { label: string; value: number }[] |
| | | > { |
| | | const list = await getStockSummaryByItem({ |
| | | stockState: 20, |
| | | warehouseType: MesWmWarehouseTypeEnum.QUALIFIED, |
| | | }); |
| | | return list.map((item: MesWmMaterialStockApi.MaterialStockSummary) => ({ |
| | | label: [item.itemName, item.itemCode, `åºå ${item.totalQuantity}`] |
| | | .filter(Boolean) |
| | | .join(' '), |
| | | value: item.itemId as number, |
| | | })); |
| | | } |
| | | |
| | | /** æå®ç©æçæ£å¼åºåæ¹æ¬¡é项 */ |
| | | export async function loadQualifiedStockBatchOptions( |
| | | itemId: number, |
| | | ): Promise<{ label: string; value: number }[]> { |
| | | const list = await getStockSummaryByBatch({ |
| | | stockState: 20, |
| | | itemId, |
| | | warehouseType: MesWmWarehouseTypeEnum.QUALIFIED, |
| | | }); |
| | | return list.map((item: MesWmMaterialStockApi.MaterialStockSummary) => ({ |
| | | label: [item.batchCode, `åºå ${item.totalQuantity}`].filter(Boolean).join(' '), |
| | | value: item.batchId as number, |
| | | })); |
| | | } |
| | | |
| | | /** |
| | | * å¯åè´§æ°éï¼æéç©æ/æ¹æ¬¡çæ£å¼åºåï¼stockState = 20ï¼æ±æ»ã |
| | | * |
| | | * æå®æ¹æ¬¡æ¶ç²¾ç¡®å°è¯¥æ¹æ¬¡ï¼å¦åè¿åç©æå
¨é¨æ£å¼åºåã |
| | | */ |
| | | export async function loadAvailableQuantity( |
| | | itemId?: number, |
| | | batchId?: number, |
| | | ): Promise<number> { |
| | | if (!itemId) { |
| | | return 0; |
| | | } |
| | | if (batchId) { |
| | | const list = await getStockSummaryByBatch({ |
| | | stockState: 20, |
| | | itemId, |
| | | warehouseType: MesWmWarehouseTypeEnum.QUALIFIED, |
| | | }); |
| | | return list.find((item) => item.batchId === batchId)?.totalQuantity ?? 0; |
| | | } |
| | | const list = await getStockSummaryByItem({ |
| | | stockState: 20, |
| | | itemId, |
| | | warehouseType: MesWmWarehouseTypeEnum.QUALIFIED, |
| | | }); |
| | | return list.find((item) => item.itemId === itemId)?.totalQuantity ?? 0; |
| | | } |
| | | |
| | | /** |
| | | * éç¥åè¡æ°å¢/ä¿®æ¹ç表åã |
| | | * |
| | | * ç©æãæ¹æ¬¡å仿£å¼åºåºåï¼stockState = 20ï¼ä¸éæ©ï¼ |
| | | * æ¹æ¬¡é项éç©æèå¨å è½½ï¼å¯ç¨åºåä¸è¶³ç±å端ä¿åæ¶æ ¡éªã |
| | | */ |
| | | export function useLineFormSchema(): VbenFormSchema[] { |
| | | return [ |
| | | { |
| | | fieldName: 'itemId', |
| | | label: 'ç©æ', |
| | | component: markRaw(MdItemSelect), |
| | | component: 'Select', |
| | | componentProps: { |
| | | placeholder: 'è¯·éæ©ç©æ', |
| | | allowClear: true, |
| | | filterOption: true, |
| | | optionFilterProp: 'label', |
| | | options: [], |
| | | placeholder: 'è¯·éæ©ç©æï¼ä»
æ£å¼åºåºåï¼', |
| | | showSearch: true, |
| | | }, |
| | | rules: 'selectRequired', |
| | | }, |
| | | { |
| | | fieldName: 'batchId', |
| | | label: 'æ¹æ¬¡', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | filterOption: true, |
| | | optionFilterProp: 'label', |
| | | options: [], |
| | | placeholder: 'è¯·éæ©æ¹æ¬¡ï¼ä»
æ£å¼åºåºåï¼', |
| | | showSearch: true, |
| | | }, |
| | | rules: 'selectRequired', |
| | | }, |
| | |
| | | componentProps: { |
| | | class: '!w-full', |
| | | min: 0.01, |
| | | placeholder: '请è¾å
¥åè´§æ°é', |
| | | placeholder: '请è¾å
¥åè´§æ°éï¼ä¸å¯è¶
è¿æ£å¼åºåï¼', |
| | | precision: 2, |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'batchCode', |
| | | label: 'æ¹æ¬¡å·', |
| | | fieldName: 'availableQuantity', |
| | | label: 'å¯åè´§æ°é', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥æ¹æ¬¡å·', |
| | | disabled: true, |
| | | placeholder: 'éæ©ç©æ/æ¹æ¬¡åèªå¨å¸¦åº', |
| | | }, |
| | | }, |
| | | { |
| | |
| | | <script lang="ts" setup> |
| | | import type { FormType } from '../data'; |
| | | |
| | | import type { ErpSaleOrderApi } from '#/api/erp/sale/order'; |
| | | import type { MesWmSalesNoticeApi } from '#/api/mes/wm/salesnotice'; |
| | | |
| | | import { computed, ref } from 'vue'; |
| | |
| | | submitSalesNotice, |
| | | updateSalesNotice, |
| | | } from '#/api/mes/wm/salesnotice'; |
| | | import { createSalesNoticeLine } from '#/api/mes/wm/salesnotice/line'; |
| | | import { getSaleOrderItemListByOrderId } from '#/api/erp/sale/order'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | import { useFormSchema } from '../data'; |
| | |
| | | } |
| | | // æ°å¢ï¼ä¿æå¼¹çªæå¼ï¼åæ¢å°ç¼è¾æ¨¡å¼ |
| | | const id = await createSalesNotice(data); |
| | | // éæ©äºéå®è®¢åæ¶ï¼èªå¨å¸¦å
¥è®¢å产åæç» |
| | | if (data.saleOrderId) { |
| | | try { |
| | | const items = await getSaleOrderItemListByOrderId(data.saleOrderId); |
| | | if (items?.length) { |
| | | await Promise.all(items.map((item: ErpSaleOrderApi.SaleOrderItem) => |
| | | createSalesNoticeLine({ |
| | | noticeId: id, |
| | | saleOrderItemId: item.id, |
| | | itemId: item.productId, |
| | | itemCode: item.productBarCode, |
| | | itemName: item.productName, |
| | | unitMeasureName: item.productUnitName, |
| | | quantity: item.count, |
| | | batchCode: item.batchCode, |
| | | oqcCheckFlag: false, |
| | | }), |
| | | )); |
| | | } |
| | | } catch { |
| | | // è·å订åæç»å¤±è´¥ä¸å½±å主æµç¨ |
| | | } |
| | | } |
| | | formData.value = { |
| | | ...data, |
| | | id, |
| | |
| | | } from '#/api/mes/wm/salesnotice/line'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | import { useLineFormSchema } from '../data'; |
| | | import { |
| | | loadAvailableQuantity, |
| | | loadQualifiedStockBatchOptions, |
| | | loadQualifiedStockItemOptions, |
| | | useLineFormSchema, |
| | | } from '../data'; |
| | | |
| | | const emit = defineEmits(['success']); |
| | | const formData = ref<MesWmSalesNoticeLineApi.SalesNoticeLine>(); |
| | |
| | | ? $t('ui.actionTitle.edit', ['åè´§éç¥åè¡']) |
| | | : $t('ui.actionTitle.create', ['åè´§éç¥åè¡']); |
| | | }); |
| | | |
| | | async function updateBatchOptions(itemId?: number) { |
| | | const batchOptions = itemId |
| | | ? await loadQualifiedStockBatchOptions(itemId) |
| | | : []; |
| | | formApi.updateSchema([ |
| | | { |
| | | fieldName: 'batchId', |
| | | componentProps: { options: batchOptions }, |
| | | }, |
| | | ]); |
| | | } |
| | | |
| | | async function refreshAvailableQuantity(itemId?: number, batchId?: number) { |
| | | const quantity = await loadAvailableQuantity(itemId, batchId); |
| | | await formApi.setFieldValue( |
| | | 'availableQuantity', |
| | | quantity > 0 ? `${quantity}` : undefined, |
| | | ); |
| | | } |
| | | |
| | | const [Form, formApi] = useVbenForm({ |
| | | commonConfig: { |
| | |
| | | layout: 'horizontal', |
| | | schema: useLineFormSchema(), |
| | | showDefaultActions: false, |
| | | handleValuesChange: async (values, fieldsChanged) => { |
| | | // setValues ç¼è¾åå¡«æ¶ itemId/batchId åæ¶ååï¼ä¸æ¸
æ¹æ¬¡ä»
å·æ°é项 |
| | | const itemChanged = fieldsChanged.includes('itemId'); |
| | | const batchChanged = fieldsChanged.includes('batchId'); |
| | | if (itemChanged && batchChanged) { |
| | | await updateBatchOptions(values.itemId as number | undefined); |
| | | await refreshAvailableQuantity( |
| | | values.itemId as number | undefined, |
| | | values.batchId as number | undefined, |
| | | ); |
| | | return; |
| | | } |
| | | if (itemChanged) { |
| | | await formApi.setFieldValue('batchId', undefined); |
| | | await updateBatchOptions(values.itemId as number | undefined); |
| | | await refreshAvailableQuantity(values.itemId as number | undefined); |
| | | return; |
| | | } |
| | | if (batchChanged) { |
| | | await updateBatchOptions(values.itemId as number | undefined); |
| | | await refreshAvailableQuantity( |
| | | values.itemId as number | undefined, |
| | | values.batchId as number | undefined, |
| | | ); |
| | | } |
| | | }, |
| | | wrapperClass: 'grid-cols-3', |
| | | }); |
| | | |
| | |
| | | return; |
| | | } |
| | | modalApi.lock(); |
| | | // æäº¤è¡¨å |
| | | const data = |
| | | (await formApi.getValues()) as MesWmSalesNoticeLineApi.SalesNoticeLine; |
| | | // æäº¤è¡¨åï¼å¯åè´§æ°éä»
ä½æç¤ºï¼ä¸æäº¤å°åç«¯ï¼ |
| | | const values = (await formApi.getValues()) as MesWmSalesNoticeLineApi.SalesNoticeLine & { |
| | | availableQuantity?: unknown; |
| | | }; |
| | | delete values.availableQuantity; |
| | | const data = values; |
| | | data.noticeId = noticeId.value; |
| | | try { |
| | | await (formData.value?.id |
| | |
| | | await modalApi.close(); |
| | | emit('success'); |
| | | message.success($t('ui.actionMessage.operationSuccess')); |
| | | } catch (error) { |
| | | const response = error as { |
| | | response?: { data?: { message?: string; msg?: string } }; |
| | | }; |
| | | message.error( |
| | | response.response?.data?.message ?? |
| | | response.response?.data?.msg ?? |
| | | 'ä¿å失败ï¼è¯·æ£æ¥æ£å¼åºåºåæ¯å¦å
è¶³', |
| | | ); |
| | | } finally { |
| | | modalApi.unlock(); |
| | | } |
| | |
| | | // å è½½æ°æ® |
| | | const data = modalApi.getData<{ id?: number; noticeId: number }>(); |
| | | noticeId.value = data.noticeId; |
| | | if (!data.id) { |
| | | return; |
| | | } |
| | | modalApi.lock(); |
| | | try { |
| | | // ç©æé项ï¼ä»
å±ç¤ºæ£å¼åºæåºåçç©æ |
| | | const itemOptions = await loadQualifiedStockItemOptions(); |
| | | formApi.updateSchema([ |
| | | { |
| | | fieldName: 'itemId', |
| | | componentProps: { options: itemOptions }, |
| | | }, |
| | | ]); |
| | | if (!data.id) { |
| | | return; |
| | | } |
| | | formData.value = await getSalesNoticeLine(data.id); |
| | | // è®¾ç½®å° values |
| | | // è®¾ç½®å° valuesï¼èå¨å è½½æ¹æ¬¡é项 |
| | | await formApi.setValues(formData.value); |
| | | await updateBatchOptions(formData.value?.itemId); |
| | | await refreshAvailableQuantity( |
| | | formData.value?.itemId, |
| | | formData.value?.batchId, |
| | | ); |
| | | } finally { |
| | | modalApi.unlock(); |
| | | } |
| | |
| | | field: 'batchCode', |
| | | title: 'æ¹æ¬¡å·', |
| | | width: 120, |
| | | slots: { default: 'batchCode' }, |
| | | }, |
| | | { |
| | | field: 'quantity', |
| | |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { MesWmTransactionApi } from '#/api/mes/wm/transaction'; |
| | | |
| | | import { Page } from '@vben/common-ui'; |
| | | import { Page, useVbenModal } from '@vben/common-ui'; |
| | | |
| | | import { Tag } from 'ant-design-vue'; |
| | | import { Button, Tag } from 'ant-design-vue'; |
| | | |
| | | import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { getTransactionPage } from '#/api/mes/wm/transaction'; |
| | | |
| | | import QrTraceModal from '#/views/mes/pro/components/qr-trace-modal.vue'; |
| | | |
| | | import { useGridColumns, useGridFormSchema } from './data'; |
| | | |
| | | const [QrTraceQueryModal, qrTraceQueryModalApi] = useVbenModal({ |
| | | connectedComponent: QrTraceModal, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | function handleBatchClick(row: MesWmTransactionApi.Transaction) { |
| | | if (!row.batchCode) { |
| | | return; |
| | | } |
| | | qrTraceQueryModalApi.setData({ code: row.batchCode }).open(); |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | formOptions: { |
| | |
| | | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <QrTraceQueryModal /> |
| | | <Grid table-title="åºåäºå¡æµæ°´"> |
| | | <template #batchCode="{ row }"> |
| | | <Button |
| | | v-if="row.batchCode" |
| | | type="link" |
| | | class="!px-0" |
| | | @click="handleBatchClick(row)" |
| | | > |
| | | {{ row.batchCode }} |
| | | </Button> |
| | | <span v-else>-</span> |
| | | </template> |
| | | <template #typeName="{ row }"> |
| | | <Tag :color="row.type === 1 ? 'green' : 'red'"> |
| | | {{ row.typeName || '-' }} |
| | |
| | | |
| | | import { h } from 'vue'; |
| | | |
| | | import { DICT_TYPE, MesAutoCodeRuleCode } from '@vben/constants'; |
| | | import { DICT_TYPE, MesAutoCodeRuleCode, MesWmWarehouseTypeEnum } from '@vben/constants'; |
| | | |
| | | import { Button } from 'ant-design-vue'; |
| | | |
| | |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'type', |
| | | label: 'ä»åºç±»å', |
| | | component: 'Select', |
| | | componentProps: { |
| | | placeholder: 'è¯·éæ©ä»åºç±»å', |
| | | options: [ |
| | | { label: 'æååº', value: MesWmWarehouseTypeEnum.TEMP }, |
| | | { label: 'åæ ¼åº', value: MesWmWarehouseTypeEnum.QUALIFIED }, |
| | | ], |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'frozen', |
| | | label: 'æ¯å¦å»ç»', |
| | | component: 'Switch', |
| | |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥ä»åºåç§°', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'type', |
| | | label: 'ä»åºç±»å', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | options: [ |
| | | { label: 'æååº', value: MesWmWarehouseTypeEnum.TEMP }, |
| | | { label: 'åæ ¼åº', value: MesWmWarehouseTypeEnum.QUALIFIED }, |
| | | ], |
| | | placeholder: 'è¯·éæ©ä»åºç±»å', |
| | | }, |
| | | }, |
| | | { |
| | |
| | | userList.find((user) => user.id === cellValue)?.nickname ?? '', |
| | | }, |
| | | { |
| | | field: 'type', |
| | | title: 'ä»åºç±»å', |
| | | width: 100, |
| | | formatter: ({ cellValue }) => |
| | | cellValue === MesWmWarehouseTypeEnum.TEMP |
| | | ? 'æååº' |
| | | : cellValue === MesWmWarehouseTypeEnum.QUALIFIED |
| | | ? 'åæ ¼åº' |
| | | : '', |
| | | }, |
| | | { |
| | | field: 'frozen', |
| | | title: 'å»ç»', |
| | | width: 90, |