feat(mes): 追溯功能新增消费者扫码分析和窜货预警模块
- 新增消费者扫码访问趋势和分布图表功能
- 实现窜货预警管理和防窜货规则配置
- 添加数据完整性校验和追溯码H5页面支持
- 优化二维码展示内容和扫码逻辑
- 集成GSAP动画增强界面交互体验
- 完善追溯相关API接口和数据结构定义
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace MesTraceConsumerScanApi { |
| | | /** æ¶è´¹è
æ«ç è®¿é®æ±æ» */ |
| | | export interface Summary { |
| | | total: number; // æ»è®¿é®æ¬¡æ° |
| | | success: number; // æåæ¬¡æ° |
| | | failed: number; // å¤±è´¥æ¬¡æ° |
| | | uniqueBatchCount: number; // éç©ºæ¹æ¬¡å»éæ°é |
| | | } |
| | | |
| | | /** æ¶è´¹è
æ«ç 访é®è¶å¿ */ |
| | | export interface Trend { |
| | | date: string; // æ¥æ yyyy-MM-dd |
| | | total: number; // 彿¥æ»è®¿é®æ¬¡æ° |
| | | success: number; // 彿¥æåæ¬¡æ° |
| | | failed: number; // 彿¥å¤±è´¥æ¬¡æ° |
| | | } |
| | | |
| | | /** æ¶è´¹è
æ«ç 访é®åå¸ */ |
| | | export interface Distribution { |
| | | name: string; // åç»åç§° |
| | | count: number; // è®¿é®æ¬¡æ° |
| | | } |
| | | } |
| | | |
| | | /** è·å¾æ¶è´¹è
æ«ç è®¿é®æ±æ» */ |
| | | export function getTraceConsumerScanSummary( |
| | | startTime?: string, |
| | | endTime?: string, |
| | | ) { |
| | | return requestClient.get<MesTraceConsumerScanApi.Summary>( |
| | | '/mes/trace-consumer-scan/summary', |
| | | { params: { startTime, endTime } }, |
| | | ); |
| | | } |
| | | |
| | | /** è·å¾æ¶è´¹è
æ«ç 访é®è¶å¿ */ |
| | | export function getTraceConsumerScanTrend( |
| | | startTime?: string, |
| | | endTime?: string, |
| | | ) { |
| | | return requestClient.get<MesTraceConsumerScanApi.Trend[]>( |
| | | '/mes/trace-consumer-scan/trend', |
| | | { params: { startTime, endTime } }, |
| | | ); |
| | | } |
| | | |
| | | /** è·å¾æ¶è´¹è
æ«ç 访é®åå¸ */ |
| | | export function getTraceConsumerScanDistribution( |
| | | groupBy: string, |
| | | startTime?: string, |
| | | endTime?: string, |
| | | ) { |
| | | return requestClient.get<MesTraceConsumerScanApi.Distribution[]>( |
| | | '/mes/trace-consumer-scan/distribution', |
| | | { params: { groupBy, startTime, endTime } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace MesTraceConfigApi { |
| | | /** æº¯æº H5 页é¢åºç¡å°åï¼äºç»´ç å
容åç¼ï¼ */ |
| | | export interface QrConfig { |
| | | traceBaseUrl?: string; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * è·å¾æº¯æº H5 页é¢åºç¡å°åï¼äºç»´ç å
容åç¼ï¼ |
| | | * ç¨äº"æ¥çäºç»´ç "é¢è§/ä¸è½½æ¶çæä¸æå°æ ç¾ä¸è´ç H5 URL äºç»´ç |
| | | */ |
| | | export function getQrConfig() { |
| | | return requestClient.get<MesTraceConfigApi.QrConfig>( |
| | | '/mes/trace-config/qr-config', |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace MesTraceCrossRegionApi { |
| | | /** çªè´§é¢è¦ */ |
| | | export interface Warning { |
| | | id?: number; // ç¼å· |
| | | warningNo?: string; // é¢è¦ç¼å· |
| | | bagCodeId?: number; // è¢ç ç¼å· |
| | | bagCode?: string; // è¢ç |
| | | palletCodeId?: number; // æçç ç¼å· |
| | | palletCode?: string; // æçç |
| | | batchId?: number; // çäº§æ¹æ¬¡ç¼å· |
| | | batchCode?: string; // æ¹æ¬¡å· |
| | | itemId?: number; // ç©æç¼å· |
| | | itemCode?: string; // ç©æç¼ç |
| | | itemName?: string; // ç©æåç§° |
| | | salesId?: number; // éå®åºåºåç¼å· |
| | | salesCode?: string; // åºåºåå· |
| | | clientId?: number; // ç»éåç¼å· |
| | | clientName?: string; // ç»éååç§° |
| | | dealerProvince?: string; // ç»éåç份 |
| | | dealerCity?: string; // ç»éååå¸ |
| | | scanProvince?: string; // æ«ç ç份 |
| | | scanCity?: string; // æ«ç åå¸ |
| | | warningType?: string; // é¢è¦ç±»åï¼CROSS_PROVINCE / CROSS_CITY |
| | | warningTypeName?: string; // é¢è¦ç±»ååç§° |
| | | scanEventId?: number; // å
³èæ¶è´¹è
æ«ç äºä»¶ç¼å· |
| | | status?: number; // ç¶æï¼10 å¾
å¤ç / 20 å·²å¤ç / 30 è¯¯æ¥ |
| | | statusName?: string; // ç¶æåç§° |
| | | handleUserId?: number; // å¤ç人ç¼å· |
| | | handleUserName?: string; // å¤ç人å§å |
| | | handleTime?: string; // å¤çæ¶é´ |
| | | handleRemark?: string; // å¤ç夿³¨ |
| | | eventTime?: string; // é¢è¦äº§çæ¶é´ |
| | | } |
| | | |
| | | /** çªè´§é¢è¦å页æ¥è¯¢åæ° */ |
| | | export interface PageParams extends PageParam { |
| | | status?: number; |
| | | warningType?: string; |
| | | batchCode?: string; |
| | | bagCode?: string; |
| | | clientName?: string; |
| | | eventTime?: string[]; |
| | | } |
| | | |
| | | /** çªè´§é¢è¦æ±æ» */ |
| | | export interface Summary { |
| | | [key: string]: number | undefined; // æ±æ»ææ ï¼æåæ®µååå¼ï¼ |
| | | total?: number; // é¢è¦æ»æ° |
| | | pending?: number; // å¾
å¤çæ°é |
| | | handled?: number; // å·²å¤çæ°é |
| | | falseAlarm?: number; // è¯¯æ¥æ°é |
| | | crossProvince?: number; // è·¨ççªè´§æ°é |
| | | crossCity?: number; // è·¨å¸çªè´§æ°é |
| | | } |
| | | |
| | | /** çªè´§é¢è¦åå¸ç»è®¡é¡¹ */ |
| | | export interface StatisticsItem { |
| | | name?: string; // åç»åç§° |
| | | count?: number; // é¢è¦æ°é |
| | | } |
| | | |
| | | /** é²çªè´§è§åé
ç½® */ |
| | | export interface AntiConfig { |
| | | warningLevel?: string; // å¤å®ç²åº¦ï¼PROVINCE è·¨ç / CITY è·¨å¸ |
| | | remark?: string; // é
置说æ |
| | | } |
| | | } |
| | | |
| | | /** è·å¾çªè´§é¢è¦å页 */ |
| | | export function getTraceCrossRegionPage( |
| | | params: MesTraceCrossRegionApi.PageParams, |
| | | ) { |
| | | return requestClient.get<PageResult<MesTraceCrossRegionApi.Warning>>( |
| | | '/mes/trace-cross-region/page', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** å¤ççªè´§é¢è¦ */ |
| | | export function handleTraceCrossRegion(data: { |
| | | id: number; |
| | | status: number; |
| | | handleRemark?: string; |
| | | }) { |
| | | return requestClient.put('/mes/trace-cross-region/handle', data); |
| | | } |
| | | |
| | | /** è·å¾çªè´§é¢è¦æ±æ» */ |
| | | export function getTraceCrossRegionSummary(startTime?: string, endTime?: string) { |
| | | return requestClient.get<MesTraceCrossRegionApi.Summary>( |
| | | '/mes/trace-cross-region/summary', |
| | | { params: { startTime, endTime } }, |
| | | ); |
| | | } |
| | | |
| | | /** è·å¾çªè´§é¢è¦åå¸ç»è®¡ */ |
| | | export function getTraceCrossRegionStatistics( |
| | | groupBy: string, |
| | | startTime?: string, |
| | | endTime?: string, |
| | | ) { |
| | | return requestClient.get<MesTraceCrossRegionApi.StatisticsItem[]>( |
| | | '/mes/trace-cross-region/statistics', |
| | | { params: { groupBy, startTime, endTime } }, |
| | | ); |
| | | } |
| | | |
| | | /** è·å¾é²çªè´§è§åé
ç½® */ |
| | | export function getTraceCrossRegionConfig() { |
| | | return requestClient.get<MesTraceCrossRegionApi.AntiConfig>( |
| | | '/mes/trace-cross-region/config', |
| | | ); |
| | | } |
| | | |
| | | /** æ´æ°é²çªè´§è§åé
ç½® */ |
| | | export function updateTraceCrossRegionConfig(warningLevel: string) { |
| | | return requestClient.put( |
| | | `/mes/trace-cross-region/config?warningLevel=${warningLevel}`, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace MesTraceIntegrityApi { |
| | | /** æ°æ®å®æ´æ§æ ¡éªç»æ */ |
| | | export interface VerifyResult { |
| | | bizType?: string; // ä¸å¡ç±»åï¼BATCH / SALES / SCAN |
| | | bizId?: number; // ä¸å¡è®°å½ç¼å· |
| | | bizCode?: string; // ä¸å¡ç¼ç |
| | | matched?: boolean; // æ¯å¦æ ¡éªéè¿ |
| | | storedHash?: string; // åå¨çæè¦ SHA-256 |
| | | currentHash?: string; // å½åæ°æ®éç®æè¦ SHA-256 |
| | | bagSetHash?: string; // æ¹æ¬¡å½åè¢ç éåæè¦ï¼ä»
BATCH æ ¡éªè¿åï¼ |
| | | message?: string; // æ ¡éªè¯´æ |
| | | } |
| | | |
| | | /** æ°æ®å®æ´æ§è®°å½ */ |
| | | export interface Record { |
| | | id?: number; // ç¼å· |
| | | bizType?: string; // ä¸å¡ç±»å |
| | | bizId?: number; // ä¸å¡è®°å½ç¼å· |
| | | bizCode?: string; // ä¸å¡ç¼ç |
| | | contentHash?: string; // å
容æè¦ SHA-256 |
| | | eventTime?: string; // æè¦çææ¶é´ |
| | | } |
| | | |
| | | /** æ°æ®å®æ´æ§è®°å½å页æ¥è¯¢åæ° */ |
| | | export interface PageParams extends PageParam { |
| | | bizType?: string; |
| | | bizId?: number; |
| | | bizCode?: string; |
| | | } |
| | | } |
| | | |
| | | /** æ ¡éªæ°æ®å®æ´æ§ï¼éç®æè¦æ¯å¯¹ï¼ */ |
| | | export function verifyTraceIntegrity(bizType: string, bizId: number) { |
| | | return requestClient.get<MesTraceIntegrityApi.VerifyResult>( |
| | | '/mes/trace-integrity/verify', |
| | | { params: { bizType, bizId } }, |
| | | ); |
| | | } |
| | | |
| | | /** è·å¾æ°æ®å®æ´æ§è®°å½å页 */ |
| | | export function getTraceIntegrityPage( |
| | | params: MesTraceIntegrityApi.PageParams, |
| | | ) { |
| | | return requestClient.get<PageResult<MesTraceIntegrityApi.Record>>( |
| | | '/mes/trace-integrity/page', |
| | | { params }, |
| | | ); |
| | | } |
| | |
| | | import type { MesProBatchApi } from "#/api/mes/pro/batch"; |
| | | import type { MesProPalletApi } from "#/api/mes/pro/pallet"; |
| | | |
| | | import { ref } from "vue"; |
| | | import { computed, ref } from "vue"; |
| | | |
| | | import { Barcode, useVbenModal } from "@vben/common-ui"; |
| | | import { DICT_TYPE } from "@vben/constants"; |
| | |
| | | import { getBagCodeByCode } from "#/api/mes/pro/bagCode"; |
| | | import { getBatchByCode } from "#/api/mes/pro/batch"; |
| | | import { getPalletByCode } from "#/api/mes/pro/pallet"; |
| | | import { getQrConfig } from "#/api/mes/trace/config"; |
| | | import { useDescription } from "#/components/description"; |
| | | |
| | | defineOptions({ name: "MesProQrTraceModal" }); |
| | |
| | | const barcodeRef = ref<InstanceType<typeof Barcode>>(); |
| | | |
| | | const traceTypeLabel = ref(""); |
| | | |
| | | /** æº¯æº H5 页é¢åºç¡å°åï¼ä¼è¯å
åªæå䏿¬¡ï¼ */ |
| | | const traceBaseUrl = ref<string>(""); |
| | | |
| | | async function ensureTraceBaseUrl() { |
| | | if (traceBaseUrl.value) return; |
| | | try { |
| | | const res = await getQrConfig(); |
| | | traceBaseUrl.value = res.traceBaseUrl || ""; |
| | | } catch { |
| | | traceBaseUrl.value = ""; |
| | | } |
| | | } |
| | | |
| | | /** äºç»´ç å±ç¤ºå
容ï¼é
ç½®äºæº¯æºå°ååæ¼ H5 URLï¼å¦ååé纯ç å¼ï¼ä¸å端 buildQrCodeContent ä¸è´ï¼ */ |
| | | const qrContent = computed(() => { |
| | | if (!currentCode.value) return ""; |
| | | return traceBaseUrl.value |
| | | ? `${traceBaseUrl.value}?code=${encodeURIComponent(currentCode.value)}` |
| | | : currentCode.value; |
| | | }); |
| | | |
| | | /** ä»å®æ´ H5 龿¥ä¸æå追溯ç ï¼å
¼å®¹æ«ç æªæ«ææå°æ ç¾é¾æ¥ï¼ */ |
| | | function extractCodeFromUrl(raw: string): string { |
| | | const trimmed = raw.trim(); |
| | | if (/^https?:\/\//i.test(trimmed)) { |
| | | try { |
| | | const url = new URL(trimmed); |
| | | const code = url.searchParams.get("code"); |
| | | if (code) return code; |
| | | } catch { |
| | | // è§£æå¤±è´¥æåå§è¾å
¥å¤ç |
| | | } |
| | | } |
| | | return trimmed; |
| | | } |
| | | |
| | | /** è¯å«äºç»´ç å
容类åï¼æ¹æ¬¡å·ï¼å«ä¸´æ¶æ¹æ¬¡ç ï¼/ æçç ï¼å«ä¸´æ¶æçç ï¼/ è¢ç */ |
| | | function resolveCodeType(raw: string): TraceType | undefined { |
| | |
| | | |
| | | /** æ«ç /è¾å
¥äºç»´ç å
容æ¥è¯¢è¿½æº¯ä¿¡æ¯ */ |
| | | async function handleSearch(raw?: string) { |
| | | const value = (raw ?? codeInput.value)?.trim().toUpperCase(); |
| | | const input = (raw ?? codeInput.value)?.trim() ?? ""; |
| | | const value = extractCodeFromUrl(input).toUpperCase(); |
| | | if (!value) { |
| | | message.warning("è¯·æ«ææè¾å
¥äºç»´ç å
容"); |
| | | return; |
| | |
| | | if (!isOpen) { |
| | | return; |
| | | } |
| | | ensureTraceBaseUrl(); |
| | | const data = modalApi.getData<{ code?: string }>() || {}; |
| | | codeInput.value = data.code ?? undefined; |
| | | resetState(); |
| | |
| | | class="mb-4 flex items-center gap-6 rounded bg-gray-100 p-5" |
| | | > |
| | | <div class="flex-shrink-0"> |
| | | <Barcode ref="barcodeRef" :content="currentCode" :width="200" /> |
| | | <Barcode ref="barcodeRef" :content="qrContent" :width="200" /> |
| | | </div> |
| | | <div class="min-w-0 flex-1"> |
| | | <p class="text-lg font-bold break-all">{{ currentCode }}</p> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { EChartsOption } from '@vben/plugins/echarts'; |
| | | |
| | | /** æ¶è´¹è
æ«ç 访é®è¶å¿æçº¿å¾é
ç½® */ |
| | | export function getScanTrendChartOptions( |
| | | dates: string[], |
| | | totals: number[], |
| | | success: number[], |
| | | failed: number[], |
| | | ): EChartsOption { |
| | | return { |
| | | grid: { bottom: 40, left: 50, right: 20, top: 20 }, |
| | | legend: { bottom: 0, data: ['æ»è®¿é®', 'æå', '失败'] }, |
| | | series: [ |
| | | { |
| | | areaStyle: { color: 'rgba(64,158,255,0.15)' }, |
| | | data: totals, |
| | | itemStyle: { color: '#409EFF' }, |
| | | name: 'æ»è®¿é®', |
| | | smooth: true, |
| | | type: 'line', |
| | | }, |
| | | { |
| | | data: success, |
| | | itemStyle: { color: '#67C23A' }, |
| | | name: 'æå', |
| | | smooth: true, |
| | | type: 'line', |
| | | }, |
| | | { |
| | | data: failed, |
| | | itemStyle: { color: '#F56C6C' }, |
| | | name: '失败', |
| | | smooth: true, |
| | | type: 'line', |
| | | }, |
| | | ], |
| | | tooltip: { axisPointer: { type: 'cross' }, trigger: 'axis' }, |
| | | xAxis: { boundaryGap: false, data: dates, type: 'category' }, |
| | | yAxis: { minInterval: 1, type: 'value' }, |
| | | }; |
| | | } |
| | | |
| | | /** æ¶è´¹è
æ«ç 访é®åå¸é¥¼å¾é
ç½® */ |
| | | export function getScanDistributionChartOptions( |
| | | data: Array<{ itemStyle: { color: string }; name: string; value: number }>, |
| | | ): EChartsOption { |
| | | return { |
| | | legend: { bottom: 0, 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 }, |
| | | radius: ['40%', '70%'], |
| | | type: 'pie', |
| | | }, |
| | | ], |
| | | tooltip: { formatter: '{b}: {c} ({d}%)', trigger: 'item' }, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { MesTraceConsumerScanApi } from '#/api/mes/trace-consumer-scan'; |
| | | |
| | | /** æ¶è´¹è
æ«ç è®¿é®æ±æ»é»è®¤å¼ */ |
| | | export const defaultSummary: MesTraceConsumerScanApi.Summary = { |
| | | total: 0, |
| | | success: 0, |
| | | failed: 0, |
| | | uniqueBatchCount: 0, |
| | | }; |
| | | |
| | | /** åå¸åç»ç»´åº¦é项 */ |
| | | export const GROUP_BY_OPTIONS: Array<{ label: string; value: string }> = [ |
| | | { label: 'æ«ç ç±»å', value: 'sourceType' }, |
| | | { label: 'æ¥è¯¢ç»æ', value: 'success' }, |
| | | { label: 'æ«ç æ¸ é', value: 'channel' }, |
| | | { label: 'ç份', value: 'province' }, |
| | | { label: 'åå¸', value: 'city' }, |
| | | { label: 'çäº§æ¹æ¬¡', value: 'batchId' }, |
| | | ]; |
| | | |
| | | /** æ«ç ç±»ååç§°æ å°ï¼å端åå¨åå§ç ï¼å±ç¤ºæ¶è½¬æ¢ï¼ */ |
| | | export const SOURCE_TYPE_LABEL_MAP: Record<string, string> = { |
| | | BAG: 'è¢ç ', |
| | | PALLET: 'æçç ', |
| | | BATCH: 'æ¹æ¬¡ç ', |
| | | }; |
| | | |
| | | /** åå¸å¾è¡¨é¢è²éå */ |
| | | export const DISTRIBUTION_COLORS = [ |
| | | '#409EFF', |
| | | '#67C23A', |
| | | '#E6A23C', |
| | | '#F56C6C', |
| | | '#909399', |
| | | '#7C3AED', |
| | | '#00BCD4', |
| | | '#FF9800', |
| | | '#4CAF50', |
| | | '#E91E63', |
| | | ]; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, ref } from 'vue'; |
| | | |
| | | import { Page } from '@vben/common-ui'; |
| | | |
| | | import { Card, Col, DatePicker, Row } from 'ant-design-vue'; |
| | | import dayjs from 'dayjs'; |
| | | |
| | | import { getRangePickerDefaultProps } from '#/utils'; |
| | | |
| | | import DistributionChart from './modules/distribution-chart.vue'; |
| | | import SummaryCards from './modules/summary-cards.vue'; |
| | | import TrendChart from './modules/trend-chart.vue'; |
| | | |
| | | defineOptions({ name: 'MesConsumerScanStatistics' }); |
| | | |
| | | /** ç»è®¡æ¶é´èå´ï¼é»è®¤è¿ 30 天ï¼valueFormat 已转为 yyyy-MM-dd HH:mm:ssï¼ */ |
| | | const dateRange = ref<[string, string] | undefined>([ |
| | | dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss'), |
| | | dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss'), |
| | | ]); |
| | | |
| | | const startTime = computed(() => dateRange.value?.[0]); |
| | | const endTime = computed(() => dateRange.value?.[1]); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page> |
| | | <Card title="æ¶è´¹è
æ«ç ç»è®¡" class="mb-4"> |
| | | <div class="flex flex-wrap items-center gap-2"> |
| | | <span class="text-muted-foreground text-sm">ç»è®¡æ¶é´èå´</span> |
| | | <DatePicker.RangePicker |
| | | v-model:value="dateRange" |
| | | v-bind="getRangePickerDefaultProps()" |
| | | /> |
| | | </div> |
| | | </Card> |
| | | |
| | | <SummaryCards :start-time="startTime" :end-time="endTime" /> |
| | | |
| | | <Row :gutter="16"> |
| | | <Col :lg="14" :md="24" :sm="24" :xl="14" :xs="24" class="mb-4"> |
| | | <TrendChart :start-time="startTime" :end-time="endTime" /> |
| | | </Col> |
| | | <Col :lg="10" :md="24" :sm="24" :xl="10" :xs="24" class="mb-4"> |
| | | <DistributionChart :start-time="startTime" :end-time="endTime" /> |
| | | </Col> |
| | | </Row> |
| | | </Page> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { EchartsUIType } from '@vben/plugins/echarts'; |
| | | |
| | | import { computed, ref, watch } from 'vue'; |
| | | |
| | | import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
| | | |
| | | import { Card, Empty, Select } from 'ant-design-vue'; |
| | | |
| | | import { getTraceConsumerScanDistribution } from '#/api/mes/trace-consumer-scan'; |
| | | |
| | | import { getScanDistributionChartOptions } from '../chart-options'; |
| | | import { DISTRIBUTION_COLORS, GROUP_BY_OPTIONS, SOURCE_TYPE_LABEL_MAP } from '../data'; |
| | | |
| | | defineOptions({ name: 'MesTraceConsumerScanDistributionChart' }); |
| | | |
| | | const props = defineProps<{ |
| | | startTime?: string; |
| | | endTime?: string; |
| | | }>(); |
| | | |
| | | const groupBy = ref('sourceType'); // å½ååç»ç»´åº¦ |
| | | const chartRef = ref<EchartsUIType>(); |
| | | const { renderEcharts } = useEcharts(chartRef); |
| | | |
| | | /** å叿°æ®æ¯å¦ä¸ºç©º */ |
| | | const hasData = ref(false); |
| | | |
| | | /** å°å端åå§åç»å称转为å¯è¯»åç§° */ |
| | | function displayName(name: string): string { |
| | | if (groupBy.value === 'sourceType') { |
| | | return SOURCE_TYPE_LABEL_MAP[name] || name; |
| | | } |
| | | return name; |
| | | } |
| | | |
| | | /** å è½½å叿°æ®å¹¶æ¸²æé¥¼å¾ */ |
| | | async function loadData() { |
| | | const data = await getTraceConsumerScanDistribution( |
| | | groupBy.value, |
| | | props.startTime, |
| | | props.endTime, |
| | | ); |
| | | hasData.value = data.length > 0; |
| | | const chartData = data.map((d, index) => ({ |
| | | itemStyle: { |
| | | color: DISTRIBUTION_COLORS[index % DISTRIBUTION_COLORS.length] ?? '#409EFF', |
| | | }, |
| | | name: displayName(d.name), |
| | | value: d.count, |
| | | })); |
| | | await renderEcharts(getScanDistributionChartOptions(chartData)); |
| | | } |
| | | |
| | | /** 忢åç»ç»´åº¦ */ |
| | | function handleGroupByChange() { |
| | | loadData(); |
| | | } |
| | | |
| | | watch( |
| | | () => [props.startTime, props.endTime], |
| | | () => { |
| | | loadData(); |
| | | }, |
| | | { immediate: true }, |
| | | ); |
| | | |
| | | /** å½ååç»ç»´åº¦çå±ç¤ºåç§° */ |
| | | const groupByLabel = computed( |
| | | () => |
| | | GROUP_BY_OPTIONS.find((opt) => opt.value === groupBy.value)?.label || |
| | | 'åå¸', |
| | | ); |
| | | </script> |
| | | |
| | | <template> |
| | | <Card :title="`${groupByLabel}åå¸`" class="h-full"> |
| | | <template #extra> |
| | | <Select |
| | | v-model:value="groupBy" |
| | | size="small" |
| | | style="width: 120px" |
| | | @change="handleGroupByChange" |
| | | > |
| | | <Select.Option |
| | | v-for="opt in GROUP_BY_OPTIONS" |
| | | :key="opt.value" |
| | | :value="opt.value" |
| | | > |
| | | {{ opt.label }} |
| | | </Select.Option> |
| | | </Select> |
| | | </template> |
| | | <div v-if="hasData" class="relative"> |
| | | <EchartsUI ref="chartRef" class="h-[320px] w-full" /> |
| | | </div> |
| | | <Empty v-else description="ææ æ«ç æ°æ®" class="h-[320px]" /> |
| | | </Card> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { MesTraceConsumerScanApi } from '#/api/mes/trace-consumer-scan'; |
| | | |
| | | import { ref, watch } from 'vue'; |
| | | |
| | | import { CountTo } from '@vben/common-ui'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { Card, Col, Row } from 'ant-design-vue'; |
| | | |
| | | import { getTraceConsumerScanSummary } from '#/api/mes/trace-consumer-scan'; |
| | | |
| | | import { defaultSummary } from '../data'; |
| | | |
| | | defineOptions({ name: 'MesTraceConsumerScanSummaryCards' }); |
| | | |
| | | const props = defineProps<{ |
| | | startTime?: string; |
| | | endTime?: string; |
| | | }>(); |
| | | |
| | | const summary = ref<MesTraceConsumerScanApi.Summary>(defaultSummary); |
| | | |
| | | /** å è½½æ±æ»æ°æ® */ |
| | | async function loadData() { |
| | | summary.value = await getTraceConsumerScanSummary( |
| | | props.startTime, |
| | | props.endTime, |
| | | ); |
| | | } |
| | | |
| | | watch( |
| | | () => [props.startTime, props.endTime], |
| | | () => { |
| | | loadData(); |
| | | }, |
| | | { immediate: true }, |
| | | ); |
| | | </script> |
| | | |
| | | <template> |
| | | <Row :gutter="16"> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <Card class="kpi-card"> |
| | | <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: linear-gradient(135deg, #409eff, #66b1ff)" |
| | | > |
| | | <IconifyIcon class="size-7" icon="lucide:scan-line" /> |
| | | </div> |
| | | <div> |
| | | <div class="text-muted-foreground mb-1 text-sm">æ»è®¿é®æ¬¡æ°</div> |
| | | <CountTo |
| | | class="text-2xl font-bold leading-tight text-[#409eff]" |
| | | :end-val="summary.total" |
| | | :duration="1500" |
| | | /> |
| | | <div class="text-muted-foreground mt-1 text-xs">å
¨é¨æ«ç æ¥è¯¢</div> |
| | | </div> |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <Card class="kpi-card"> |
| | | <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: linear-gradient(135deg, #67c23a, #85ce61)" |
| | | > |
| | | <IconifyIcon class="size-7" icon="lucide:circle-check" /> |
| | | </div> |
| | | <div> |
| | | <div class="text-muted-foreground mb-1 text-sm">æåæ¥è¯¢</div> |
| | | <CountTo |
| | | class="text-2xl font-bold leading-tight text-[#67c23a]" |
| | | :end-val="summary.success" |
| | | :duration="1500" |
| | | /> |
| | | <div class="text-muted-foreground mt-1 text-xs"> |
| | | 溯æºä¿¡æ¯æåè¿å |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <Card class="kpi-card"> |
| | | <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: linear-gradient(135deg, #f56c6c, #f78989)" |
| | | > |
| | | <IconifyIcon class="size-7" icon="lucide:circle-x" /> |
| | | </div> |
| | | <div> |
| | | <div class="text-muted-foreground mb-1 text-sm">失败æ¥è¯¢</div> |
| | | <CountTo |
| | | class="text-2xl font-bold leading-tight text-[#f56c6c]" |
| | | :end-val="summary.failed" |
| | | :duration="1500" |
| | | /> |
| | | <div class="text-muted-foreground mt-1 text-xs">ç ä¸åå¨ææ æ</div> |
| | | </div> |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <Card class="kpi-card"> |
| | | <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: linear-gradient(135deg, #7c3aed, #9461f5)" |
| | | > |
| | | <IconifyIcon class="size-7" icon="lucide:layers" /> |
| | | </div> |
| | | <div> |
| | | <div class="text-muted-foreground mb-1 text-sm">æ¶åæ¹æ¬¡</div> |
| | | <CountTo |
| | | class="text-2xl font-bold leading-tight text-[#7c3aed]" |
| | | :end-val="summary.uniqueBatchCount" |
| | | :duration="1500" |
| | | /> |
| | | <div class="text-muted-foreground mt-1 text-xs">å»éçäº§æ¹æ¬¡</div> |
| | | </div> |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | </Row> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { EchartsUIType } from '@vben/plugins/echarts'; |
| | | |
| | | import { ref, watch } from 'vue'; |
| | | |
| | | import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
| | | |
| | | import { Card } from 'ant-design-vue'; |
| | | |
| | | import { getTraceConsumerScanTrend } from '#/api/mes/trace-consumer-scan'; |
| | | |
| | | import { getScanTrendChartOptions } from '../chart-options'; |
| | | |
| | | defineOptions({ name: 'MesTraceConsumerScanTrendChart' }); |
| | | |
| | | const props = defineProps<{ |
| | | startTime?: string; |
| | | endTime?: string; |
| | | }>(); |
| | | |
| | | const chartRef = ref<EchartsUIType>(); |
| | | const { renderEcharts } = useEcharts(chartRef); |
| | | |
| | | /** å è½½è¶å¿æ°æ®å¹¶æ¸²ææçº¿å¾ */ |
| | | async function loadData() { |
| | | const data = await getTraceConsumerScanTrend(props.startTime, props.endTime); |
| | | const dates = data.map((d) => d.date.slice(5)); // yyyy-MM-dd -> MM-dd |
| | | const totals = data.map((d) => d.total); |
| | | const success = data.map((d) => d.success); |
| | | const failed = data.map((d) => d.failed); |
| | | await renderEcharts(getScanTrendChartOptions(dates, totals, success, failed)); |
| | | } |
| | | |
| | | watch( |
| | | () => [props.startTime, props.endTime], |
| | | () => { |
| | | loadData(); |
| | | }, |
| | | { immediate: true }, |
| | | ); |
| | | </script> |
| | | |
| | | <template> |
| | | <Card title="æ«ç 访é®è¶å¿" class="h-full"> |
| | | <EchartsUI ref="chartRef" class="h-[320px] w-full" /> |
| | | </Card> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { VbenFormSchema } from '#/adapter/form'; |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { MesTraceCrossRegionApi } from '#/api/mes/trace/crossregion'; |
| | | |
| | | import { getRangePickerDefaultProps } from '#/utils'; |
| | | |
| | | /** é¢è¦ç±»åé项 */ |
| | | export const WARNING_TYPE_OPTIONS = [ |
| | | { label: 'è·¨ççªè´§', value: 'CROSS_PROVINCE' }, |
| | | { label: 'è·¨å¸çªè´§', value: 'CROSS_CITY' }, |
| | | ]; |
| | | |
| | | /** é¢è¦ç¶æé项 */ |
| | | export const WARNING_STATUS_OPTIONS = [ |
| | | { label: 'å¾
å¤ç', value: 10 }, |
| | | { label: 'å·²å¤ç', value: 20 }, |
| | | { label: '误æ¥', value: 30 }, |
| | | ]; |
| | | |
| | | /** å¤å®ç²åº¦é项 */ |
| | | export const WARNING_LEVEL_OPTIONS = [ |
| | | { label: 'è·¨çå¤å®', value: 'PROVINCE' }, |
| | | { label: 'è·¨å¸å¤å®', value: 'CITY' }, |
| | | ]; |
| | | |
| | | /** å表çæç´¢è¡¨å */ |
| | | export function useGridFormSchema(): VbenFormSchema[] { |
| | | return [ |
| | | { |
| | | fieldName: 'status', |
| | | label: 'ç¶æ', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | options: WARNING_STATUS_OPTIONS, |
| | | placeholder: 'è¯·éæ©ç¶æ', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'warningType', |
| | | label: 'é¢è¦ç±»å', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | options: WARNING_TYPE_OPTIONS, |
| | | placeholder: 'è¯·éæ©é¢è¦ç±»å', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'batchCode', |
| | | label: 'æ¹æ¬¡å·', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥æ¹æ¬¡å·', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'bagCode', |
| | | label: 'è¢ç ', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥è¢ç ', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'clientName', |
| | | label: 'ç»éå', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥ç»éååç§°', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'eventTime', |
| | | label: 'é¢è¦æ¶é´', |
| | | component: 'RangePicker', |
| | | componentProps: { |
| | | ...getRangePickerDefaultProps(), |
| | | allowClear: true, |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | /** å表çåæ®µ */ |
| | | export function useGridColumns(): VxeTableGridOptions<MesTraceCrossRegionApi.Warning>['columns'] { |
| | | return [ |
| | | { |
| | | field: 'warningNo', |
| | | title: 'é¢è¦ç¼å·', |
| | | fixed: 'left', |
| | | minWidth: 190, |
| | | }, |
| | | { |
| | | field: 'bagCode', |
| | | title: 'è¢ç ', |
| | | minWidth: 150, |
| | | slots: { default: 'bagCode' }, |
| | | }, |
| | | { |
| | | field: 'batchCode', |
| | | title: 'æ¹æ¬¡å·', |
| | | minWidth: 190, |
| | | }, |
| | | { |
| | | field: 'itemName', |
| | | title: 'ç©æåç§°', |
| | | minWidth: 120, |
| | | }, |
| | | { |
| | | field: 'salesCode', |
| | | title: 'åºåºåå·', |
| | | minWidth: 150, |
| | | }, |
| | | { |
| | | field: 'clientName', |
| | | title: 'ç»éå', |
| | | minWidth: 120, |
| | | }, |
| | | { |
| | | field: 'dealerProvince', |
| | | title: 'ç»éåå°åº', |
| | | minWidth: 130, |
| | | slots: { default: 'dealerRegion' }, |
| | | }, |
| | | { |
| | | field: 'scanProvince', |
| | | title: 'æ«ç å°åº', |
| | | minWidth: 130, |
| | | slots: { default: 'scanRegion' }, |
| | | }, |
| | | { |
| | | field: 'warningTypeName', |
| | | title: 'é¢è¦ç±»å', |
| | | width: 110, |
| | | slots: { default: 'warningType' }, |
| | | }, |
| | | { |
| | | field: 'statusName', |
| | | title: 'ç¶æ', |
| | | width: 90, |
| | | slots: { default: 'status' }, |
| | | }, |
| | | { |
| | | field: 'eventTime', |
| | | title: 'é¢è¦æ¶é´', |
| | | width: 170, |
| | | formatter: 'formatDateTime', |
| | | }, |
| | | { |
| | | field: 'handleUserName', |
| | | title: 'å¤ç人', |
| | | width: 90, |
| | | }, |
| | | { |
| | | title: 'æä½', |
| | | width: 120, |
| | | fixed: 'right', |
| | | slots: { default: 'actions' }, |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { MesTraceCrossRegionApi } from '#/api/mes/trace/crossregion'; |
| | | |
| | | import { gsap } from 'gsap'; |
| | | |
| | | import { nextTick, onMounted, onUnmounted, ref } from 'vue'; |
| | | |
| | | import { useAccess } from '@vben/access'; |
| | | import { CountTo, Page } from '@vben/common-ui'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { |
| | | Card, |
| | | Col, |
| | | message, |
| | | Modal, |
| | | Row, |
| | | Segmented, |
| | | Skeleton, |
| | | Tag, |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { |
| | | getTraceCrossRegionConfig, |
| | | getTraceCrossRegionPage, |
| | | getTraceCrossRegionSummary, |
| | | handleTraceCrossRegion, |
| | | updateTraceCrossRegionConfig, |
| | | } from '#/api/mes/trace/crossregion'; |
| | | |
| | | import { |
| | | WARNING_LEVEL_OPTIONS, |
| | | useGridColumns, |
| | | useGridFormSchema, |
| | | } from './data'; |
| | | |
| | | defineOptions({ name: 'MesTraceCrossRegion' }); |
| | | |
| | | const { hasAccessByCodes } = useAccess(); |
| | | const canHandle = hasAccessByCodes(['mes:trace-cross-region:handle']); |
| | | |
| | | /** æ±æ»æ°æ® */ |
| | | const summary = ref<MesTraceCrossRegionApi.Summary>({}); |
| | | const summaryLoading = ref(false); |
| | | |
| | | /** å¤å®ç²åº¦é
ç½® */ |
| | | const warningLevel = ref('PROVINCE'); |
| | | |
| | | /** å¤çå¼¹çªç¶æ */ |
| | | const handleVisible = ref(false); |
| | | const handleLoading = ref(false); |
| | | |
| | | /** GSAP å
¥åºå¨ç»ä¸ä¸æï¼å¸è½½æ¶èªå¨è¿åï¼ */ |
| | | let animationContext: gsap.Context | undefined; |
| | | const handleForm = ref<{ |
| | | id?: number; |
| | | status: number; |
| | | handleRemark?: string; |
| | | }>({ |
| | | id: undefined, |
| | | status: 20, |
| | | handleRemark: '', |
| | | }); |
| | | |
| | | /** æ±æ»å¡çé
ç½® */ |
| | | const summaryCards = [ |
| | | { |
| | | label: 'é¢è¦æ»æ°', |
| | | value: 'total', |
| | | icon: 'lucide:alert-triangle', |
| | | color: '#409eff', |
| | | bg: 'linear-gradient(135deg, #409eff, #66b1ff)', |
| | | }, |
| | | { |
| | | label: 'å¾
å¤ç', |
| | | value: 'pending', |
| | | icon: 'lucide:clock', |
| | | color: '#e6a23c', |
| | | bg: 'linear-gradient(135deg, #e6a23c, #ebb563)', |
| | | }, |
| | | { |
| | | label: 'å·²å¤ç', |
| | | value: 'handled', |
| | | icon: 'lucide:circle-check', |
| | | color: '#67c23a', |
| | | bg: 'linear-gradient(135deg, #67c23a, #85ce61)', |
| | | }, |
| | | { |
| | | label: '误æ¥', |
| | | value: 'falseAlarm', |
| | | icon: 'lucide:shield-x', |
| | | color: '#909399', |
| | | bg: 'linear-gradient(135deg, #909399, #b1b3b8)', |
| | | }, |
| | | { |
| | | label: 'è·¨ççªè´§', |
| | | value: 'crossProvince', |
| | | icon: 'lucide:map-pin-off', |
| | | color: '#f56c6c', |
| | | bg: 'linear-gradient(135deg, #f56c6c, #f78989)', |
| | | }, |
| | | { |
| | | label: 'è·¨å¸çªè´§', |
| | | value: 'crossCity', |
| | | icon: 'lucide:map-pin', |
| | | color: '#7c3aed', |
| | | bg: 'linear-gradient(135deg, #7c3aed, #9461f5)', |
| | | }, |
| | | ]; |
| | | |
| | | /** å è½½æ±æ» */ |
| | | async function loadSummary() { |
| | | summaryLoading.value = true; |
| | | try { |
| | | summary.value = await getTraceCrossRegionSummary(); |
| | | } finally { |
| | | summaryLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | /** å è½½å¤å®ç²åº¦é
ç½® */ |
| | | async function loadConfig() { |
| | | const config = await getTraceCrossRegionConfig(); |
| | | warningLevel.value = config.warningLevel || 'PROVINCE'; |
| | | } |
| | | |
| | | /** 忢å¤å®ç²åº¦ */ |
| | | async function handleLevelChange(value: string | number) { |
| | | try { |
| | | const level = String(value); |
| | | await updateTraceCrossRegionConfig(level); |
| | | message.success( |
| | | `å¤å®ç²åº¦å·²åæ¢ä¸º${ |
| | | WARNING_LEVEL_OPTIONS.find((opt) => opt.value === level)?.label |
| | | }`, |
| | | ); |
| | | } catch { |
| | | loadConfig(); |
| | | } |
| | | } |
| | | |
| | | /** æå¼å¤çå¼¹çª */ |
| | | function openHandle(row: MesTraceCrossRegionApi.Warning) { |
| | | handleForm.value = { id: row.id, status: 20, handleRemark: '' }; |
| | | handleVisible.value = true; |
| | | } |
| | | |
| | | /** æäº¤å¤ç */ |
| | | async function submitHandle() { |
| | | if (!handleForm.value.id || handleLoading.value) return; |
| | | handleLoading.value = true; |
| | | try { |
| | | await handleTraceCrossRegion({ |
| | | id: handleForm.value.id, |
| | | status: handleForm.value.status, |
| | | handleRemark: handleForm.value.handleRemark, |
| | | }); |
| | | message.success('é¢è¦å·²å¤ç'); |
| | | handleVisible.value = false; |
| | | handleRefresh(); |
| | | } catch { |
| | | message.error('å¤ç失败ï¼è¯·éè¯'); |
| | | } finally { |
| | | handleLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | /** å·æ°åè¡¨ä¸æ±æ» */ |
| | | function handleRefresh() { |
| | | gridApi.query(); |
| | | loadSummary(); |
| | | } |
| | | |
| | | /** å±ç¤ºå°åºç»å */ |
| | | function regionText(province?: string, city?: string) { |
| | | return [province, city].filter(Boolean).join('·') || '-'; |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | formOptions: { |
| | | schema: useGridFormSchema(), |
| | | }, |
| | | gridOptions: { |
| | | columns: useGridColumns(), |
| | | height: 'auto', |
| | | keepSource: true, |
| | | proxyConfig: { |
| | | ajax: { |
| | | query: async ({ page }, formValues) => { |
| | | return await getTraceCrossRegionPage({ |
| | | pageNo: page.currentPage, |
| | | pageSize: page.pageSize, |
| | | ...formValues, |
| | | }); |
| | | }, |
| | | }, |
| | | }, |
| | | rowConfig: { |
| | | keyField: 'id', |
| | | isHover: true, |
| | | }, |
| | | toolbarConfig: { |
| | | refresh: true, |
| | | search: true, |
| | | }, |
| | | } as VxeTableGridOptions<MesTraceCrossRegionApi.Warning>, |
| | | }); |
| | | |
| | | onMounted(() => { |
| | | loadSummary(); |
| | | loadConfig(); |
| | | initEntranceAnimation(); |
| | | }); |
| | | |
| | | /** å
¥åºå¨ç»ï¼å°é prefers-reduced-motion */ |
| | | async function initEntranceAnimation() { |
| | | await nextTick(); |
| | | animationContext = gsap.context(() => { |
| | | const reduceMotion = window.matchMedia( |
| | | '(prefers-reduced-motion: reduce)', |
| | | ).matches; |
| | | gsap.from('.mes-cr-summary, .mes-cr-grid', { |
| | | autoAlpha: reduceMotion ? 1 : 0, |
| | | y: reduceMotion ? 0 : 14, |
| | | duration: reduceMotion ? 0 : 0.45, |
| | | stagger: reduceMotion ? 0 : 0.08, |
| | | ease: 'power2.out', |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | onUnmounted(() => { |
| | | animationContext?.revert(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <Card class="mes-cr-summary mb-4"> |
| | | <template #title> |
| | | <div class="flex items-center justify-between"> |
| | | <span>çªè´§é¢è¦</span> |
| | | <span class="flex items-center gap-2 text-sm"> |
| | | <span class="text-muted-foreground">å¤å®ç²åº¦</span> |
| | | <Segmented |
| | | v-model:value="warningLevel" |
| | | :disabled="!canHandle" |
| | | :options="WARNING_LEVEL_OPTIONS" |
| | | @change="handleLevelChange" |
| | | /> |
| | | </span> |
| | | </div> |
| | | </template> |
| | | <Row :gutter="16"> |
| | | <Col |
| | | v-for="card in summaryCards" |
| | | :key="card.value" |
| | | :lg="4" |
| | | :md="8" |
| | | :sm="12" |
| | | :xs="24" |
| | | class="mb-4" |
| | | > |
| | | <Card class="kpi-card"> |
| | | <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.bg }" |
| | | > |
| | | <IconifyIcon class="size-7" :icon="card.icon" /> |
| | | </div> |
| | | <div> |
| | | <div class="text-muted-foreground mb-1 text-sm"> |
| | | {{ card.label }} |
| | | </div> |
| | | <Skeleton.Input |
| | | v-if="summaryLoading" |
| | | :size="'small'" |
| | | active |
| | | style="width: 4rem" |
| | | /> |
| | | <CountTo |
| | | v-else |
| | | class="text-2xl font-bold leading-tight" |
| | | :style="{ color: card.color }" |
| | | :end-val="summary[card.value] || 0" |
| | | :duration="1200" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | </Row> |
| | | </Card> |
| | | |
| | | <Grid class="mes-cr-grid" table-title="çªè´§é¢è¦å表"> |
| | | <template #bagCode="{ row }"> |
| | | <Tag v-if="row.bagCode" color="blue">{{ row.bagCode }}</Tag> |
| | | <Tag v-else-if="row.palletCode" color="purple"> |
| | | {{ row.palletCode }} |
| | | </Tag> |
| | | <span v-else>-</span> |
| | | </template> |
| | | <template #dealerRegion="{ row }"> |
| | | {{ regionText(row.dealerProvince, row.dealerCity) }} |
| | | </template> |
| | | <template #scanRegion="{ row }"> |
| | | <span class="text-[#dc2626] font-medium"> |
| | | {{ regionText(row.scanProvince, row.scanCity) }} |
| | | </span> |
| | | </template> |
| | | <template #warningType="{ row }"> |
| | | <Tag |
| | | :color="row.warningType === 'CROSS_PROVINCE' ? 'red' : 'orange'" |
| | | > |
| | | {{ row.warningTypeName }} |
| | | </Tag> |
| | | </template> |
| | | <template #status="{ row }"> |
| | | <Tag |
| | | :color=" |
| | | row.status === 10 |
| | | ? 'orange' |
| | | : row.status === 20 |
| | | ? 'green' |
| | | : 'default' |
| | | " |
| | | > |
| | | {{ row.statusName }} |
| | | </Tag> |
| | | </template> |
| | | <template #actions="{ row }"> |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'å¤ç', |
| | | type: 'link', |
| | | icon: ACTION_ICON.EDIT, |
| | | auth: ['mes:trace-cross-region:handle'], |
| | | ifShow: row.status === 10, |
| | | onClick: openHandle.bind(null, row), |
| | | }, |
| | | ]" |
| | | /> |
| | | </template> |
| | | </Grid> |
| | | |
| | | <Modal |
| | | v-model:open="handleVisible" |
| | | title="å¤ççªè´§é¢è¦" |
| | | :confirm-loading="handleLoading" |
| | | ok-text="确认å¤ç" |
| | | cancel-text="åæ¶" |
| | | @ok="submitHandle" |
| | | > |
| | | <a-form layout="vertical"> |
| | | <a-form-item label="å¤çç»æ"> |
| | | <a-radio-group v-model:value="handleForm.status"> |
| | | <a-radio :value="20">å·²å¤ç</a-radio> |
| | | <a-radio :value="30">误æ¥</a-radio> |
| | | </a-radio-group> |
| | | </a-form-item> |
| | | <a-form-item label="å¤ç夿³¨"> |
| | | <a-textarea |
| | | v-model:value="handleForm.handleRemark" |
| | | :rows="3" |
| | | placeholder="请è¾å
¥å¤ç夿³¨ï¼å¦ï¼ç¡®è®¤ä¸ºç©æµä¸è½¬ï¼" |
| | | /> |
| | | </a-form-item> |
| | | </a-form> |
| | | </Modal> |
| | | </Page> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { VbenFormSchema } from '#/adapter/form'; |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { MesTraceIntegrityApi } from '#/api/mes/trace/integrity'; |
| | | |
| | | /** ä¸å¡ç±»åé项 */ |
| | | export const BIZ_TYPE_OPTIONS = [ |
| | | { label: 'çäº§æ¹æ¬¡', value: 'BATCH' }, |
| | | { label: 'éå®åºåº', value: 'SALES' }, |
| | | { label: 'æ¶è´¹è
æ«ç ', value: 'SCAN' }, |
| | | ]; |
| | | |
| | | /** ä¸å¡ç±»å䏿å */ |
| | | export function getBizTypeLabel(bizType?: string): string { |
| | | return ( |
| | | BIZ_TYPE_OPTIONS.find((opt) => opt.value === bizType)?.label || bizType || '-' |
| | | ); |
| | | } |
| | | |
| | | /** å表çæç´¢è¡¨å */ |
| | | export function useGridFormSchema(): VbenFormSchema[] { |
| | | return [ |
| | | { |
| | | fieldName: 'bizType', |
| | | label: 'ä¸å¡ç±»å', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | options: BIZ_TYPE_OPTIONS, |
| | | placeholder: 'è¯·éæ©ä¸å¡ç±»å', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'bizCode', |
| | | label: 'ä¸å¡ç¼ç ', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥æ¹æ¬¡å·/åºåºåå·/追溯ç ', |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | /** å表çåæ®µ */ |
| | | export function useGridColumns(): VxeTableGridOptions<MesTraceIntegrityApi.Record>['columns'] { |
| | | return [ |
| | | { |
| | | field: 'bizType', |
| | | title: 'ä¸å¡ç±»å', |
| | | width: 110, |
| | | slots: { default: 'bizType' }, |
| | | }, |
| | | { |
| | | field: 'bizId', |
| | | title: 'ä¸å¡ç¼å·', |
| | | width: 110, |
| | | }, |
| | | { |
| | | field: 'bizCode', |
| | | title: 'ä¸å¡ç¼ç ', |
| | | minWidth: 220, |
| | | }, |
| | | { |
| | | field: 'contentHash', |
| | | title: 'å
容æè¦', |
| | | minWidth: 300, |
| | | slots: { default: 'contentHash' }, |
| | | }, |
| | | { |
| | | field: 'eventTime', |
| | | title: 'æè¦çææ¶é´', |
| | | width: 170, |
| | | formatter: 'formatDateTime', |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { MesTraceIntegrityApi } from '#/api/mes/trace/integrity'; |
| | | |
| | | import { gsap } from 'gsap'; |
| | | |
| | | import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; |
| | | |
| | | import { Page } from '@vben/common-ui'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { |
| | | Button, |
| | | Card, |
| | | InputNumber, |
| | | message, |
| | | Select, |
| | | Tag, |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { |
| | | getTraceIntegrityPage, |
| | | verifyTraceIntegrity, |
| | | } from '#/api/mes/trace/integrity'; |
| | | |
| | | import { BIZ_TYPE_OPTIONS, getBizTypeLabel, useGridColumns, useGridFormSchema } from './data'; |
| | | |
| | | defineOptions({ name: 'MesTraceIntegrity' }); |
| | | |
| | | /** æ ¡éªè¾å
¥ */ |
| | | const verifyBizType = ref('BATCH'); |
| | | const verifyBizId = ref<number>(); |
| | | const verifyLoading = ref(false); |
| | | |
| | | /** æ ¡éªç»æ */ |
| | | const verifyResult = ref<MesTraceIntegrityApi.VerifyResult>(); |
| | | |
| | | /** GSAP å
¥åºå¨ç»ä¸ä¸æï¼å¸è½½æ¶èªå¨è¿åï¼ */ |
| | | let animationContext: gsap.Context | undefined; |
| | | let resultTween: gsap.core.Tween | undefined; |
| | | |
| | | /** æ§è¡æ ¡éª */ |
| | | async function handleVerify() { |
| | | if (!verifyBizId.value) { |
| | | message.warning('请è¾å
¥ä¸å¡è®°å½ç¼å·'); |
| | | return; |
| | | } |
| | | if (verifyLoading.value) return; |
| | | verifyLoading.value = true; |
| | | try { |
| | | verifyResult.value = await verifyTraceIntegrity( |
| | | verifyBizType.value, |
| | | verifyBizId.value, |
| | | ); |
| | | } catch { |
| | | message.error('æ ¡éªå¤±è´¥ï¼è¯·ç¨åéè¯'); |
| | | } finally { |
| | | verifyLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | onMounted(async () => { |
| | | await nextTick(); |
| | | animationContext = gsap.context(() => { |
| | | const reduceMotion = window.matchMedia( |
| | | '(prefers-reduced-motion: reduce)', |
| | | ).matches; |
| | | gsap.from('.mes-it-verify, .mes-it-grid', { |
| | | autoAlpha: reduceMotion ? 1 : 0, |
| | | y: reduceMotion ? 0 : 14, |
| | | duration: reduceMotion ? 0 : 0.45, |
| | | stagger: reduceMotion ? 0 : 0.08, |
| | | ease: 'power2.out', |
| | | }); |
| | | }); |
| | | }); |
| | | |
| | | onUnmounted(() => { |
| | | resultTween?.kill(); |
| | | animationContext?.revert(); |
| | | }); |
| | | |
| | | /** æ ¡éªç»æå¡çåºç°æ¶çè½»éå
¥åº */ |
| | | watch(verifyResult, async (result) => { |
| | | if (!result) return; |
| | | await nextTick(); |
| | | resultTween?.kill(); |
| | | resultTween = gsap.from('.mes-it-result', { |
| | | autoAlpha: 0, |
| | | y: 8, |
| | | duration: 0.35, |
| | | ease: 'power2.out', |
| | | }); |
| | | }); |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | formOptions: { |
| | | schema: useGridFormSchema(), |
| | | }, |
| | | gridOptions: { |
| | | columns: useGridColumns(), |
| | | height: 'auto', |
| | | keepSource: true, |
| | | proxyConfig: { |
| | | ajax: { |
| | | query: async ({ page }, formValues) => { |
| | | return await getTraceIntegrityPage({ |
| | | pageNo: page.currentPage, |
| | | pageSize: page.pageSize, |
| | | ...formValues, |
| | | }); |
| | | }, |
| | | }, |
| | | }, |
| | | rowConfig: { |
| | | keyField: 'id', |
| | | isHover: true, |
| | | }, |
| | | toolbarConfig: { |
| | | refresh: true, |
| | | search: true, |
| | | }, |
| | | } as VxeTableGridOptions<MesTraceIntegrityApi.Record>, |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <Card title="æ°æ®å®æ´æ§æ ¡éª" class="mes-it-verify mb-4"> |
| | | <div class="flex flex-wrap items-center gap-3"> |
| | | <span class="text-muted-foreground text-sm">ä¸å¡ç±»å</span> |
| | | <Select |
| | | v-model:value="verifyBizType" |
| | | :options="BIZ_TYPE_OPTIONS" |
| | | class="w-40" |
| | | placeholder="è¯·éæ©ä¸å¡ç±»å" |
| | | /> |
| | | <span class="text-muted-foreground text-sm">ä¸å¡ç¼å·</span> |
| | | <InputNumber |
| | | v-model:value="verifyBizId" |
| | | class="w-48" |
| | | :min="1" |
| | | :precision="0" |
| | | placeholder="请è¾å
¥ä¸å¡è®°å½ç¼å·" |
| | | @press-enter="handleVerify" |
| | | /> |
| | | <Button |
| | | v-access:code="['mes:trace-integrity:verify']" |
| | | type="primary" |
| | | :loading="verifyLoading" |
| | | @click="handleVerify" |
| | | > |
| | | åèµ·æ ¡éª |
| | | </Button> |
| | | </div> |
| | | |
| | | <div |
| | | v-if="verifyResult" |
| | | class="mes-it-result mt-4 rounded-lg border border-dashed p-4" |
| | | :class=" |
| | | verifyResult.matched |
| | | ? 'border-[#67c23a] bg-[#67c23a]/5' |
| | | : 'border-[#f56c6c] bg-[#f56c6c]/5' |
| | | " |
| | | > |
| | | <div class="flex items-center gap-2"> |
| | | <IconifyIcon |
| | | class="size-6" |
| | | :class=" |
| | | verifyResult.matched ? 'text-[#67c23a]' : 'text-[#f56c6c]' |
| | | " |
| | | :icon=" |
| | | verifyResult.matched ? 'lucide:circle-check' : 'lucide:circle-x' |
| | | " |
| | | /> |
| | | <span class="text-base font-bold"> |
| | | {{ verifyResult.matched ? 'æ ¡éªéè¿' : 'æ ¡éªä¸éè¿' }} |
| | | </span> |
| | | <span class="text-muted-foreground ml-2 text-sm"> |
| | | {{ verifyResult.message }} |
| | | </span> |
| | | </div> |
| | | <div class="mt-3 grid gap-3 text-sm md:grid-cols-2"> |
| | | <div class="min-w-0"> |
| | | <div class="text-muted-foreground mb-1">ä¸å¡ä¿¡æ¯</div> |
| | | <div class="break-all"> |
| | | {{ getBizTypeLabel(verifyResult.bizType) }} |
| | | <span class="text-muted-foreground">#{{ verifyResult.bizId }}</span> |
| | | <Tag v-if="verifyResult.bizCode" class="ml-1"> |
| | | {{ verifyResult.bizCode }} |
| | | </Tag> |
| | | </div> |
| | | </div> |
| | | <div class="min-w-0"> |
| | | <div class="text-muted-foreground mb-1">å卿è¦ï¼SHA-256ï¼</div> |
| | | <div class="break-all font-mono text-xs"> |
| | | {{ verifyResult.storedHash }} |
| | | </div> |
| | | </div> |
| | | <div class="min-w-0"> |
| | | <div class="text-muted-foreground mb-1">å½åæè¦ï¼SHA-256ï¼</div> |
| | | <div |
| | | class="break-all font-mono text-xs" |
| | | :class="verifyResult.matched ? '' : 'text-[#f56c6c]'" |
| | | > |
| | | {{ verifyResult.currentHash }} |
| | | </div> |
| | | </div> |
| | | <div v-if="verifyResult.bagSetHash" class="min-w-0"> |
| | | <div class="text-muted-foreground mb-1">è¢ç éåæè¦</div> |
| | | <div class="break-all font-mono text-xs"> |
| | | {{ verifyResult.bagSetHash }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </Card> |
| | | |
| | | <Grid class="mes-it-grid" table-title="宿´æ§è®°å½å表"> |
| | | <template #bizType="{ row }"> |
| | | <Tag color="blue">{{ getBizTypeLabel(row.bizType) }}</Tag> |
| | | </template> |
| | | <template #contentHash="{ row }"> |
| | | <span class="font-mono text-xs">{{ row.contentHash }}</span> |
| | | </template> |
| | | </Grid> |
| | | </Page> |
| | | </template> |