From 10dd6590cea8f20eff21025ee71c8a614a48cdb7 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期二, 01 九月 2026 13:58:23 +0800
Subject: [PATCH] feat(mes): 追溯功能新增消费者扫码分析和窜货预警模块
---
src/views/mes/trace/crossregion/data.ts | 165 ++++++
src/views/mes/trace-consumer-scan/chart-options.ts | 62 ++
src/views/mes/trace-consumer-scan/modules/trend-chart.vue | 47 +
src/views/mes/trace/integrity/data.ts | 76 ++
src/views/mes/trace-consumer-scan/data.ts | 40 +
src/api/mes/trace/integrity/index.ts | 52 +
src/api/mes/trace-consumer-scan/index.ts | 59 ++
src/api/mes/trace/config.ts | 18
src/views/mes/trace/crossregion/index.vue | 375 +++++++++++++
src/api/mes/trace/crossregion/index.ts | 124 ++++
src/views/mes/trace/integrity/index.vue | 229 ++++++++
src/views/mes/trace-consumer-scan/modules/summary-cards.vue | 130 ++++
src/views/mes/trace-consumer-scan/modules/distribution-chart.vue | 99 +++
src/views/mes/pro/components/qr-trace-modal.vue | 45 +
src/views/mes/trace-consumer-scan/index.vue | 50 +
15 files changed, 1,568 insertions(+), 3 deletions(-)
diff --git a/src/api/mes/trace-consumer-scan/index.ts b/src/api/mes/trace-consumer-scan/index.ts
new file mode 100644
index 0000000..14d738b
--- /dev/null
+++ b/src/api/mes/trace-consumer-scan/index.ts
@@ -0,0 +1,59 @@
+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 } },
+ );
+}
diff --git a/src/api/mes/trace/config.ts b/src/api/mes/trace/config.ts
new file mode 100644
index 0000000..075486f
--- /dev/null
+++ b/src/api/mes/trace/config.ts
@@ -0,0 +1,18 @@
+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',
+ );
+}
diff --git a/src/api/mes/trace/crossregion/index.ts b/src/api/mes/trace/crossregion/index.ts
new file mode 100644
index 0000000..13d54be
--- /dev/null
+++ b/src/api/mes/trace/crossregion/index.ts
@@ -0,0 +1,124 @@
+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; // 棰勮绫诲瀷锛欳ROSS_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; // 鍒ゅ畾绮掑害锛歅ROVINCE 璺ㄧ渷 / 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}`,
+ );
+}
diff --git a/src/api/mes/trace/integrity/index.ts b/src/api/mes/trace/integrity/index.ts
new file mode 100644
index 0000000..2b14929
--- /dev/null
+++ b/src/api/mes/trace/integrity/index.ts
@@ -0,0 +1,52 @@
+import type { PageParam, PageResult } from '@vben/request';
+
+import { requestClient } from '#/api/request';
+
+export namespace MesTraceIntegrityApi {
+ /** 鏁版嵁瀹屾暣鎬ф牎楠岀粨鏋� */
+ export interface VerifyResult {
+ bizType?: string; // 涓氬姟绫诲瀷锛欱ATCH / 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 },
+ );
+}
diff --git a/src/views/mes/pro/components/qr-trace-modal.vue b/src/views/mes/pro/components/qr-trace-modal.vue
index f94946a..7834a3f 100644
--- a/src/views/mes/pro/components/qr-trace-modal.vue
+++ b/src/views/mes/pro/components/qr-trace-modal.vue
@@ -3,7 +3,7 @@
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";
@@ -14,6 +14,7 @@
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" });
@@ -34,6 +35,42 @@
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 {
+ // 瑙f瀽澶辫触鎸夊師濮嬭緭鍏ュ鐞�
+ }
+ }
+ return trimmed;
+ }
/** 璇嗗埆浜岀淮鐮佸唴瀹圭被鍨嬶細鎵规鍙凤紙鍚复鏃舵壒娆$爜锛�/ 鎵樼洏鐮侊紙鍚复鏃舵墭鐩樼爜锛�/ 琚嬬爜 */
function resolveCodeType(raw: string): TraceType | undefined {
@@ -66,7 +103,8 @@
/** 鎵爜/杈撳叆浜岀淮鐮佸唴瀹规煡璇㈣拷婧俊鎭� */
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;
@@ -265,6 +303,7 @@
if (!isOpen) {
return;
}
+ ensureTraceBaseUrl();
const data = modalApi.getData<{ code?: string }>() || {};
codeInput.value = data.code ?? undefined;
resetState();
@@ -512,7 +551,7 @@
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>
diff --git a/src/views/mes/trace-consumer-scan/chart-options.ts b/src/views/mes/trace-consumer-scan/chart-options.ts
new file mode 100644
index 0000000..0e6426f
--- /dev/null
+++ b/src/views/mes/trace-consumer-scan/chart-options.ts
@@ -0,0 +1,62 @@
+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' },
+ };
+}
diff --git a/src/views/mes/trace-consumer-scan/data.ts b/src/views/mes/trace-consumer-scan/data.ts
new file mode 100644
index 0000000..fa434f9
--- /dev/null
+++ b/src/views/mes/trace-consumer-scan/data.ts
@@ -0,0 +1,40 @@
+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',
+];
diff --git a/src/views/mes/trace-consumer-scan/index.vue b/src/views/mes/trace-consumer-scan/index.vue
new file mode 100644
index 0000000..0a6da66
--- /dev/null
+++ b/src/views/mes/trace-consumer-scan/index.vue
@@ -0,0 +1,50 @@
+<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>
diff --git a/src/views/mes/trace-consumer-scan/modules/distribution-chart.vue b/src/views/mes/trace-consumer-scan/modules/distribution-chart.vue
new file mode 100644
index 0000000..622a64f
--- /dev/null
+++ b/src/views/mes/trace-consumer-scan/modules/distribution-chart.vue
@@ -0,0 +1,99 @@
+<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>
diff --git a/src/views/mes/trace-consumer-scan/modules/summary-cards.vue b/src/views/mes/trace-consumer-scan/modules/summary-cards.vue
new file mode 100644
index 0000000..16b0dc0
--- /dev/null
+++ b/src/views/mes/trace-consumer-scan/modules/summary-cards.vue
@@ -0,0 +1,130 @@
+<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>
diff --git a/src/views/mes/trace-consumer-scan/modules/trend-chart.vue b/src/views/mes/trace-consumer-scan/modules/trend-chart.vue
new file mode 100644
index 0000000..31d3980
--- /dev/null
+++ b/src/views/mes/trace-consumer-scan/modules/trend-chart.vue
@@ -0,0 +1,47 @@
+<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>
diff --git a/src/views/mes/trace/crossregion/data.ts b/src/views/mes/trace/crossregion/data.ts
new file mode 100644
index 0000000..c702144
--- /dev/null
+++ b/src/views/mes/trace/crossregion/data.ts
@@ -0,0 +1,165 @@
+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' },
+ },
+ ];
+}
diff --git a/src/views/mes/trace/crossregion/index.vue b/src/views/mes/trace/crossregion/index.vue
new file mode 100644
index 0000000..a3cf073
--- /dev/null
+++ b/src/views/mes/trace/crossregion/index.vue
@@ -0,0 +1,375 @@
+<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>
diff --git a/src/views/mes/trace/integrity/data.ts b/src/views/mes/trace/integrity/data.ts
new file mode 100644
index 0000000..d154635
--- /dev/null
+++ b/src/views/mes/trace/integrity/data.ts
@@ -0,0 +1,76 @@
+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',
+ },
+ ];
+}
diff --git a/src/views/mes/trace/integrity/index.vue b/src/views/mes/trace/integrity/index.vue
new file mode 100644
index 0000000..a21c28f
--- /dev/null
+++ b/src/views/mes/trace/integrity/index.vue
@@ -0,0 +1,229 @@
+<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">瀛樺偍鎽樿锛圫HA-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">褰撳墠鎽樿锛圫HA-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>
--
Gitblit v1.9.3