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