11 小时以前 10dd6590cea8f20eff21025ee71c8a614a48cdb7
feat(mes): 追溯功能新增消费者扫码分析和窜货预警模块

- 新增消费者扫码访问趋势和分布图表功能
- 实现窜货预警管理和防窜货规则配置
- 添加数据完整性校验和追溯码H5页面支持
- 优化二维码展示内容和扫码逻辑
- 集成GSAP动画增强界面交互体验
- 完善追溯相关API接口和数据结构定义
已添加14个文件
已修改1个文件
1571 ■■■■■ 文件已修改
src/api/mes/trace-consumer-scan/index.ts 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/trace/config.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/trace/crossregion/index.ts 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/trace/integrity/index.ts 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/components/qr-trace-modal.vue 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/trace-consumer-scan/chart-options.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/trace-consumer-scan/data.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/trace-consumer-scan/index.vue 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/trace-consumer-scan/modules/distribution-chart.vue 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/trace-consumer-scan/modules/summary-cards.vue 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/trace-consumer-scan/modules/trend-chart.vue 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/trace/crossregion/data.ts 165 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/trace/crossregion/index.vue 375 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/trace/integrity/data.ts 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/trace/integrity/index.vue 229 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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 } },
  );
}
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',
  );
}
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; // é¢„警类型:CROSS_PROVINCE / CROSS_CITY
    warningTypeName?: string; // é¢„警类型名称
    scanEventId?: number; // å…³è”消费者扫码事件编号
    status?: number; // çŠ¶æ€ï¼š10 å¾…处理 / 20 å·²å¤„理 / 30 è¯¯æŠ¥
    statusName?: string; // çŠ¶æ€åç§°
    handleUserId?: number; // å¤„理人编号
    handleUserName?: string; // å¤„理人姓名
    handleTime?: string; // å¤„理时间
    handleRemark?: string; // å¤„理备注
    eventTime?: string; // é¢„警产生时间
  }
  /** çªœè´§é¢„警分页查询参数 */
  export interface PageParams extends PageParam {
    status?: number;
    warningType?: string;
    batchCode?: string;
    bagCode?: string;
    clientName?: string;
    eventTime?: string[];
  }
  /** çªœè´§é¢„警汇总 */
  export interface Summary {
    [key: string]: number | undefined; // æ±‡æ€»æŒ‡æ ‡ï¼ˆæŒ‰å­—段名取值)
    total?: number; // é¢„警总数
    pending?: number; // å¾…处理数量
    handled?: number; // å·²å¤„理数量
    falseAlarm?: number; // è¯¯æŠ¥æ•°é‡
    crossProvince?: number; // è·¨çœçªœè´§æ•°é‡
    crossCity?: number; // è·¨å¸‚窜货数量
  }
  /** çªœè´§é¢„警分布统计项 */
  export interface StatisticsItem {
    name?: string; // åˆ†ç»„名称
    count?: number; // é¢„警数量
  }
  /** é˜²çªœè´§è§„则配置 */
  export interface AntiConfig {
    warningLevel?: string; // åˆ¤å®šç²’度:PROVINCE è·¨çœ / CITY è·¨å¸‚
    remark?: string; // é…ç½®è¯´æ˜Ž
  }
}
/** èŽ·å¾—çªœè´§é¢„è­¦åˆ†é¡µ */
export function getTraceCrossRegionPage(
  params: MesTraceCrossRegionApi.PageParams,
) {
  return requestClient.get<PageResult<MesTraceCrossRegionApi.Warning>>(
    '/mes/trace-cross-region/page',
    { params },
  );
}
/** å¤„理窜货预警 */
export function handleTraceCrossRegion(data: {
  id: number;
  status: number;
  handleRemark?: string;
}) {
  return requestClient.put('/mes/trace-cross-region/handle', data);
}
/** èŽ·å¾—çªœè´§é¢„è­¦æ±‡æ€» */
export function getTraceCrossRegionSummary(startTime?: string, endTime?: string) {
  return requestClient.get<MesTraceCrossRegionApi.Summary>(
    '/mes/trace-cross-region/summary',
    { params: { startTime, endTime } },
  );
}
/** èŽ·å¾—çªœè´§é¢„è­¦åˆ†å¸ƒç»Ÿè®¡ */
export function getTraceCrossRegionStatistics(
  groupBy: string,
  startTime?: string,
  endTime?: string,
) {
  return requestClient.get<MesTraceCrossRegionApi.StatisticsItem[]>(
    '/mes/trace-cross-region/statistics',
    { params: { groupBy, startTime, endTime } },
  );
}
/** èŽ·å¾—é˜²çªœè´§è§„åˆ™é…ç½® */
export function getTraceCrossRegionConfig() {
  return requestClient.get<MesTraceCrossRegionApi.AntiConfig>(
    '/mes/trace-cross-region/config',
  );
}
/** æ›´æ–°é˜²çªœè´§è§„则配置 */
export function updateTraceCrossRegionConfig(warningLevel: string) {
  return requestClient.put(
    `/mes/trace-cross-region/config?warningLevel=${warningLevel}`,
  );
}
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; // ä¸šåŠ¡ç±»åž‹ï¼šBATCH / SALES / SCAN
    bizId?: number; // ä¸šåŠ¡è®°å½•ç¼–å·
    bizCode?: string; // ä¸šåŠ¡ç¼–ç 
    matched?: boolean; // æ˜¯å¦æ ¡éªŒé€šè¿‡
    storedHash?: string; // å­˜å‚¨çš„æ‘˜è¦ SHA-256
    currentHash?: string; // å½“前数据重算摘要 SHA-256
    bagSetHash?: string; // æ‰¹æ¬¡å½“前袋码集合摘要(仅 BATCH æ ¡éªŒè¿”回)
    message?: string; // æ ¡éªŒè¯´æ˜Ž
  }
  /** æ•°æ®å®Œæ•´æ€§è®°å½• */
  export interface Record {
    id?: number; // ç¼–号
    bizType?: string; // ä¸šåŠ¡ç±»åž‹
    bizId?: number; // ä¸šåŠ¡è®°å½•ç¼–å·
    bizCode?: string; // ä¸šåŠ¡ç¼–ç 
    contentHash?: string; // å†…容摘要 SHA-256
    eventTime?: string; // æ‘˜è¦ç”Ÿæˆæ—¶é—´
  }
  /** æ•°æ®å®Œæ•´æ€§è®°å½•分页查询参数 */
  export interface PageParams extends PageParam {
    bizType?: string;
    bizId?: number;
    bizCode?: string;
  }
}
/** æ ¡éªŒæ•°æ®å®Œæ•´æ€§ï¼ˆé‡ç®—摘要比对) */
export function verifyTraceIntegrity(bizType: string, bizId: number) {
  return requestClient.get<MesTraceIntegrityApi.VerifyResult>(
    '/mes/trace-integrity/verify',
    { params: { bizType, bizId } },
  );
}
/** èŽ·å¾—æ•°æ®å®Œæ•´æ€§è®°å½•åˆ†é¡µ */
export function getTraceIntegrityPage(
  params: MesTraceIntegrityApi.PageParams,
) {
  return requestClient.get<PageResult<MesTraceIntegrityApi.Record>>(
    '/mes/trace-integrity/page',
    { params },
  );
}
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 {
        // è§£æžå¤±è´¥æŒ‰åŽŸå§‹è¾“å…¥å¤„ç†
      }
    }
    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>
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' },
  };
}
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',
];
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>
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>
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>
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>
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' },
    },
  ];
}
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>
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',
    },
  ];
}
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">存储摘要(SHA-256)</div>
            <div class="break-all font-mono text-xs">
              {{ verifyResult.storedHash }}
            </div>
          </div>
          <div class="min-w-0">
            <div class="text-muted-foreground mb-1">当前摘要(SHA-256)</div>
            <div
              class="break-all font-mono text-xs"
              :class="verifyResult.matched ? '' : 'text-[#f56c6c]'"
            >
              {{ verifyResult.currentHash }}
            </div>
          </div>
          <div v-if="verifyResult.bagSetHash" class="min-w-0">
            <div class="text-muted-foreground mb-1">袋码集合摘要</div>
            <div class="break-all font-mono text-xs">
              {{ verifyResult.bagSetHash }}
            </div>
          </div>
        </div>
      </div>
    </Card>
    <Grid class="mes-it-grid" table-title="完整性记录列表">
      <template #bizType="{ row }">
        <Tag color="blue">{{ getBizTypeLabel(row.bizType) }}</Tag>
      </template>
      <template #contentHash="{ row }">
        <span class="font-mono text-xs">{{ row.contentHash }}</span>
      </template>
    </Grid>
  </Page>
</template>