From a55ad6ba920b2fc8e6abc84f3285041914e565d6 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期一, 24 八月 2026 16:04:18 +0800
Subject: [PATCH] feat(mes): 添加扫码入库功能模块

---
 src/views/mes/pro/scan-in/index.vue   |  210 +++++++++++++++++++++++
 src/views/mes/pro/scanEvent/index.vue |  210 +++++++++++++++++++++++
 package.json                          |    2 
 pnpm-lock.yaml                        |   19 +
 src/api/mes/pro/scanEvent/index.ts    |   57 ++++++
 5 files changed, 494 insertions(+), 4 deletions(-)

diff --git a/package.json b/package.json
index f8cce41..08c8f5e 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
     "#/*": "./src/*"
   },
   "dependencies": {
+    "@ant-design/icons-vue": "^7.0.1",
     "@form-create/ant-design-vue": "catalog:",
     "@form-create/antd-designer": "catalog:",
     "@tinymce/tinymce-vue": "catalog:",
@@ -61,6 +62,7 @@
     "dhtmlx-gantt": "catalog:",
     "diagram-js": "catalog:",
     "fast-xml-parser": "catalog:",
+    "gsap": "^3.15.0",
     "highlight.js": "catalog:",
     "livekit-client": "^2.20.0",
     "pinia": "catalog:",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 81f3c62..af265b9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -542,6 +542,9 @@
 
   .:
     dependencies:
+      '@ant-design/icons-vue':
+        specifier: ^7.0.1
+        version: 7.0.1(vue@3.5.38(typescript@6.0.3))
       '@form-create/ant-design-vue':
         specifier: 'catalog:'
         version: 3.3.1(vue@3.5.38(typescript@6.0.3))
@@ -644,6 +647,9 @@
       fast-xml-parser:
         specifier: 'catalog:'
         version: 4.5.6
+      gsap:
+        specifier: ^3.15.0
+        version: 3.15.0
       highlight.js:
         specifier: 'catalog:'
         version: 11.11.1
@@ -1744,8 +1750,8 @@
   '@ant-design/colors@6.0.0':
     resolution: {integrity: sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==}
 
-  '@ant-design/icons-svg@4.4.2':
-    resolution: {integrity: sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==}
+  '@ant-design/icons-svg@4.5.0':
+    resolution: {integrity: sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==}
 
   '@ant-design/icons-vue@7.0.1':
     resolution: {integrity: sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==}
@@ -6614,6 +6620,9 @@
   graceful-fs@4.2.11:
     resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
 
+  gsap@3.15.0:
+    resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
+
   gzip-size@7.0.0:
     resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==}
     engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -9904,12 +9913,12 @@
     dependencies:
       '@ctrl/tinycolor': 3.6.1
 
-  '@ant-design/icons-svg@4.4.2': {}
+  '@ant-design/icons-svg@4.5.0': {}
 
   '@ant-design/icons-vue@7.0.1(vue@3.5.38(typescript@6.0.3))':
     dependencies:
       '@ant-design/colors': 6.0.0
-      '@ant-design/icons-svg': 4.4.2
+      '@ant-design/icons-svg': 4.5.0
       vue: 3.5.38(typescript@6.0.3)
 
   '@antfu/install-pkg@1.1.0':
@@ -14987,6 +14996,8 @@
 
   graceful-fs@4.2.11: {}
 
+  gsap@3.15.0: {}
+
   gzip-size@7.0.0:
     dependencies:
       duplexer: 0.1.2
diff --git a/src/api/mes/pro/scanEvent/index.ts b/src/api/mes/pro/scanEvent/index.ts
new file mode 100644
index 0000000..d09dd24
--- /dev/null
+++ b/src/api/mes/pro/scanEvent/index.ts
@@ -0,0 +1,57 @@
+import type { PageParam, PageResult } from '@vben/request';
+
+import { requestClient } from '#/api/request';
+
+export namespace MesProScanEventApi {
+  export interface ScanEvent {
+    id?: number;
+    eventId?: string;
+    bagCode?: string;
+    batchCode?: string;
+    quantity?: number;
+    deviceCode?: string;
+    lineCode?: string;
+    warehouseId?: number;
+    eventTime?: string;
+    transactionId?: number;
+    status?: 'SUCCESS' | 'FAILED';
+    errorCode?: string;
+    errorMessage?: string;
+    receivedTime?: string;
+    failedTime?: string;
+    eventTime?: string;
+    remark?: string;
+  }
+
+  export interface Dashboard {
+    latest?: ScanEvent;
+    total?: number;
+  }
+
+  export interface InboundParams {
+    eventId: string;
+    bagCode: string;
+    quantity: number;
+    deviceCode: string;
+    lineCode?: string;
+    locationId: number;
+    areaId: number;
+    remark?: string;
+  }
+}
+
+export function inboundScanEvent(data: MesProScanEventApi.InboundParams) {
+  return requestClient.post<MesProScanEventApi.ScanEvent>('/mes/pro/scan-in/inbound', data);
+}
+
+export function getScanEventPage(params: PageParam & { deviceCode?: string; bagCode?: string }) {
+  return requestClient.get<PageResult<MesProScanEventApi.ScanEvent>>('/mes/pro/scan-in/page', { params });
+}
+
+export function getLatestScanEvent(deviceCode?: string) {
+  return requestClient.get<MesProScanEventApi.ScanEvent>('/mes/pro/scan-in/latest', { params: { deviceCode } });
+}
+
+export function getScanDashboard() {
+  return requestClient.get<MesProScanEventApi.Dashboard>('/mes/pro/scan-in/dashboard');
+}
diff --git a/src/views/mes/pro/scan-in/index.vue b/src/views/mes/pro/scan-in/index.vue
new file mode 100644
index 0000000..7042afc
--- /dev/null
+++ b/src/views/mes/pro/scan-in/index.vue
@@ -0,0 +1,210 @@
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue';
+
+import { message } from 'ant-design-vue';
+import { IconifyIcon } from '@vben/icons';
+import { gsap } from 'gsap';
+
+import {
+  getLatestScanEvent,
+  getScanDashboard,
+  getScanEventPage,
+  inboundScanEvent,
+  type MesProScanEventApi,
+} from '#/api/mes/pro/scanEvent';
+
+interface ScanRecord extends MesProScanEventApi.ScanEvent {
+  key: number;
+}
+
+const code = ref('');
+const deviceCode = ref('');
+const lineCode = ref('');
+const locationId = ref<number>();
+const areaId = ref<number>();
+const loading = ref(false);
+const refreshing = ref(false);
+const records = ref<ScanRecord[]>([]);
+const dashboard = ref<MesProScanEventApi.Dashboard>({});
+let timer: ReturnType<typeof setInterval> | undefined;
+
+const latest = computed(() => dashboard.value.latest);
+const successCount = computed(() => dashboard.value.success ?? records.value.filter((item) => item.status === 'SUCCESS').length);
+const failedCount = computed(() => dashboard.value.failed ?? records.value.filter((item) => item.status === 'FAILED').length);
+const isReady = computed(() => Boolean(deviceCode.value.trim() && locationId.value && areaId.value));
+const pageRoot = ref<HTMLElement>();
+let animationContext: gsap.Context | undefined;
+let feedbackTimeline: gsap.core.Timeline | undefined;
+
+async function refresh() {
+  refreshing.value = true;
+  try {
+    const [summary, page] = await Promise.all([
+      getScanDashboard(),
+      getScanEventPage({ pageNo: 1, pageSize: 20, deviceCode: deviceCode.value.trim() || undefined }),
+    ]);
+    dashboard.value = summary;
+    records.value = (page.list ?? []).map((item, index) => ({ ...item, key: item.id ?? index }));
+  } finally {
+    refreshing.value = false;
+  }
+}
+
+async function submitScan() {
+  const bagCode = code.value.trim();
+  if (!bagCode || !deviceCode.value.trim()) {
+    message.warning('璇峰厛濉啓璁惧缂栫爜锛屽啀鎵弿琚嬬爜');
+    return;
+  }
+  if (!locationId.value || !areaId.value) {
+    message.warning('璇峰厛閰嶇疆 WIP 鏆傚瓨搴撳尯鍜屽簱浣�');
+    return;
+  }
+  loading.value = true;
+  try {
+    await inboundScanEvent({
+      eventId: `${deviceCode.value.trim()}-${Date.now()}`,
+      bagCode,
+      quantity: 1,
+      deviceCode: deviceCode.value.trim(),
+      lineCode: lineCode.value.trim() || undefined,
+      locationId: locationId.value,
+      areaId: areaId.value,
+    });
+    message.success(`琚嬬爜 ${bagCode} 宸插畬鎴愬叆搴揱);
+    if (animationContext && pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+      feedbackTimeline?.kill();
+      feedbackTimeline = gsap.timeline();
+      feedbackTimeline
+        .to('.scan-ring', { scale: 1.16, duration: 0.18, ease: 'power2.out' })
+        .to('.scan-ring', { scale: 1, duration: 0.5, ease: 'elastic.out(1, 0.45)' })
+        .to('.scan-card', { boxShadow: '0 0 0 3px #b7ebd2, 0 12px 30px #20b87820', duration: 0.2 }, 0)
+        .to('.scan-card', { boxShadow: '0 3px 12px #243b5310', duration: 0.65 });
+    }
+    code.value = '';
+    await refresh();
+  } catch (error) {
+    const response = error as { message?: string; msg?: string };
+    message.error(response.message ?? response.msg ?? '鎵爜鍏ュ簱澶辫触锛岃鏌ョ湅浜嬩欢璁板綍');
+    await refresh();
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function refreshLatest() {
+  if (deviceCode.value.trim()) await getLatestScanEvent(deviceCode.value.trim());
+  await refresh();
+}
+
+onMounted(async () => {
+  await nextTick();
+  if (pageRoot.value) {
+    animationContext = gsap.context(() => {
+      const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+      gsap.from('.page-header, .metric-card, .scan-card, .status-card, .records-card', {
+        autoAlpha: reduceMotion ? 1 : 0,
+        y: reduceMotion ? 0 : 14,
+        duration: reduceMotion ? 0 : 0.45,
+        stagger: reduceMotion ? 0 : 0.06,
+        ease: 'power2.out',
+      });
+      if (!reduceMotion) {
+        gsap.to('.status-dot', {
+          scale: 1.12,
+          boxShadow: '0 0 0 5px #d9f7e9',
+          duration: 1.2,
+          repeat: -1,
+          yoyo: true,
+          ease: 'sine.inOut',
+        });
+        gsap.to('.scan-ring', {
+          boxShadow: '0 0 0 10px #eaf3ff, 0 0 24px #1677ff30',
+          duration: 1.8,
+          repeat: -1,
+          yoyo: true,
+          ease: 'sine.inOut',
+        });
+      }
+    }, pageRoot.value);
+  }
+  await refresh();
+  timer = setInterval(refreshLatest, 15000);
+});
+
+onBeforeUnmount(() => {
+  if (timer) clearInterval(timer);
+});
+
+onUnmounted(() => {
+  feedbackTimeline?.kill();
+  animationContext?.revert();
+});
+</script>
+
+<template>
+  <div ref="pageRoot" class="scan-page">
+    <header class="page-header">
+      <div>
+        <div class="eyebrow">MES / 涓�琚嬩竴鐮佺鐞�</div>
+        <h1>鐢熶骇绾挎壂鐮佸叆搴�</h1>
+        <p>瀹炴椂鎺ユ敹杈圭紭璁惧瑙g爜缁撴灉锛岃嚜鍔ㄧ櫥璁板叆搴撳苟鍚屾 WIP 鏆傚瓨搴撳瓨</p>
+      </div>
+      <div class="header-status"><span class="status-dot" aria-hidden="true" /><span>瀹炴椂鐩戞帶涓�</span> <a-button type="text" :loading="refreshing" aria-label="鍒锋柊鏁版嵁" @click="refresh"><IconifyIcon icon="ant-design:reload-outlined" /></a-button></div>
+    </header>
+
+    <section class="metric-grid" aria-label="鍏ュ簱缁熻">
+      <div class="metric-card"><div class="metric-icon blue"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><span>绱鎵爜浜嬩欢</span><strong>{{ dashboard.total ?? 0 }}</strong><small>鎸佺画鎺ユ敹</small></div></div>
+      <div class="metric-card"><div class="metric-icon green"><IconifyIcon icon="ant-design:check-circle-outlined" /></div><div><span>鎴愬姛鍏ュ簱</span><strong>{{ successCount }}</strong><small>宸插啓鍏ュ簱瀛樹簨鍔�</small></div></div>
+      <div class="metric-card"><div class="metric-icon red"><IconifyIcon icon="ant-design:warning-outlined" /></div><div><span>澶辫触浜嬩欢</span><strong>{{ failedCount }}</strong><small>闇�鍏虫敞寮傚父璁板綍</small></div></div>
+      <div class="metric-card"><div class="metric-icon orange"><IconifyIcon icon="ant-design:inbox-outlined" /></div><div><span>鏈�杩戝叆搴撹鐮�</span><strong class="text-value">{{ latest?.bagCode ?? '-' }}</strong><small>{{ latest?.eventTime ?? '鏆傛棤璁板綍' }}</small></div></div>
+    </section>
+
+    <section class="main-grid">
+      <a-card class="scan-card" :bordered="false">
+        <template #title><div class="card-title"><IconifyIcon icon="ant-design:scan-outlined" />鎵爜鍏ュ簱 <a-tag color="blue">鑷姩妯″紡</a-tag></div></template>
+        <div class="scan-panel">
+          <div class="scan-intro"><div class="scan-ring"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><h2>绛夊緟鎵爜</h2><p>灏嗚鐮佸鍑嗗伐涓氱浉鏈猴紝鎴栦娇鐢ㄦ壂鐮佹灙鎵弿鍚庡洖杞︾‘璁�</p></div></div>
+          <a-input-search v-model:value="code" class="scan-input" size="large" enter-button="纭鍏ュ簱" :loading="loading" :disabled="loading" placeholder="璇锋壂鎻忚鐮侊紝鎵爜鏋洖杞﹀悗鑷姩鍏ュ簱" @search="submitScan" />
+          <div class="scan-hint"><IconifyIcon icon="ant-design:clock-circle-outlined" /> 鏈嶅姟绔褰曞噯纭叆搴撴椂闂� <span>路</span> <IconifyIcon icon="ant-design:inbox-outlined" /> 姣忚鏁伴噺鎸� 1 璁�</div>
+        </div>
+        <a-divider />
+        <a-form layout="vertical" class="config-form">
+          <a-form-item label="璁惧缂栫爜" required><a-input v-model:value="deviceCode" prefix="璁惧" placeholder="渚嬪锛欳AM-LINE-01" /></a-form-item>
+          <a-form-item label="浜х嚎缂栫爜"><a-input v-model:value="lineCode" placeholder="渚嬪锛歀INE-A" /></a-form-item>
+          <a-form-item label="WIP 鏆傚瓨搴撳尯" required><a-input-number v-model:value="locationId" class="full-width" :min="1" placeholder="搴撳尯缂栧彿" /></a-form-item>
+          <a-form-item label="WIP 鏆傚瓨搴撲綅" required><a-input-number v-model:value="areaId" class="full-width" :min="1" placeholder="搴撲綅缂栧彿" /></a-form-item>
+        </a-form>
+      </a-card>
+
+      <a-card class="status-card" :bordered="false">
+        <template #title><div class="card-title"><ApiOutlined />閲囬泦璁惧鐘舵��</div></template>
+        <div class="device-state"><span class="online-badge"><span class="status-dot" aria-hidden="true" /><span>{{ isReady ? '璁惧宸插氨缁�' : '绛夊緟閰嶇疆' }}</span></span><span class="muted">15 绉掕嚜鍔ㄥ埛鏂�</span></div>
+        <div class="status-list"><div><span>褰撳墠璁惧</span><strong>{{ deviceCode || '鏈厤缃�' }}</strong></div><div><span>鐢熶骇浜х嚎</span><strong>{{ lineCode || '鏈厤缃�' }}</strong></div><div><span>鏆傚瓨浣嶇疆</span><strong>{{ locationId && areaId ? `${locationId} 鍖� / ${areaId} 浣峘 : '鏈厤缃�' }}</strong></div><div><span>鏈�杩戝叆搴撴椂闂�</span><strong>{{ latest?.eventTime || '-' }}</strong></div><div><span>鏈�杩戠敓浜ф壒娆�</span><strong>{{ latest?.batchCode || '-' }}</strong></div></div>
+        <div class="sync-box"><IconifyIcon icon="ant-design:check-circle-outlined" /><div><strong>浠撳偍绯荤粺鍚屾姝e父</strong><span>鎵爜浜嬩欢涓庡簱瀛樹簨鍔″疄鏃跺啓鍏�</span></div></div>
+      </a-card>
+    </section>
+
+    <a-card class="records-card" :bordered="false">
+      <template #title><div class="card-title"><IconifyIcon icon="ant-design:clock-circle-outlined" />鏈�杩戞壂鐮佷簨浠� <span class="record-count">鏈�杩� 20 鏉�</span></div></template>
+      <a-table :data-source="records" :pagination="false" :scroll="{ x: 1050 }" row-key="key" size="middle">
+        <a-table-column key="bagCode" title="琚嬬爜" data-index="bagCode" /><a-table-column key="batchCode" title="鐢熶骇鎵规" data-index="batchCode" /><a-table-column key="deviceCode" title="璁惧 / 浜х嚎"><template #default="{ record }">{{ record.deviceCode || '-' }}<span class="sub-text">{{ record.lineCode || '-' }}</span></template></a-table-column>
+        <a-table-column key="status" title="鐘舵��"><template #default="{ record }"><a-tag :color="record.status === 'SUCCESS' ? 'success' : 'error'">{{ record.status === 'SUCCESS' ? '鎴愬姛鍏ュ簱' : '澶辫触' }}</a-tag></template></a-table-column><a-table-column key="errorCode" title="閿欒鐮�" data-index="errorCode" /><a-table-column key="errorMessage" title="澶辫触鍘熷洜" data-index="errorMessage" /><a-table-column key="eventTime" title="澶勭悊鏃堕棿" data-index="eventTime" /><a-table-column key="transactionId" title="搴撳瓨浜嬪姟" data-index="transactionId" />
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<style scoped>
+.scan-page { min-height: 100%; padding: 24px; background: #f4f7fb; color: #172033; }
+.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 22px; }
+.eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
+h1 { margin: 6px 0; font-size: 28px; font-weight: 700; } .page-header p { margin: 0; color: #718096; }
+.header-status, .device-state { display: flex; align-items: center; gap: 10px; color: #16835b; font-size: 13px; font-weight: 600; }.header-status :deep(.ant-btn) { color: #607089; }
+.status-dot { display: inline-block; flex: 0 0 8px; width: 8px; height: 8px; margin: 0 2px 0 1px; border-radius: 50%; background: #20b878; box-shadow: 0 0 0 4px #d9f7e9; transform-origin: center; }
+.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 18px; }.metric-card { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px; background: #fff; border: 1px solid #e8edf5; border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.metric-card > div:last-child { min-width: 0; padding-top: 1px; }.metric-icon { flex: 0 0 42px; display: grid; place-items: center; width: 42px; height: 42px; margin-top: 1px; border-radius: 9px; font-size: 20px; }.metric-icon.blue { color: #1677ff; background: #eaf3ff; }.metric-icon.green { color: #17a673; background: #e6f8f0; }.metric-icon.red { color: #e05252; background: #fff0f0; }.metric-icon.orange { color: #d58a17; background: #fff6df; }.metric-card span, .metric-card small { display: block; color: #7b8799; font-size: 12px; }.metric-card strong { display: block; margin: 4px 0; font-size: 25px; line-height: 1.1; }.metric-card .text-value { overflow: hidden; max-width: 180px; text-overflow: ellipsis; white-space: nowrap; font-size: 18px; }
+.main-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .8fr); gap: 18px; margin-bottom: 18px; }.scan-card, .status-card, .records-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 10px; min-height: 24px; font-weight: 700; }.card-title :deep(.iconify) { flex: 0 0 auto; margin-top: 1px; }.card-title :deep(.anticon) { color: #1677ff; }.card-title .ant-tag { margin-left: 4px; font-weight: 500; }.scan-panel { padding: 8px 0 4px; }.scan-intro { display: flex; align-items: center; gap: 16px; margin: 4px 0 22px; }.scan-ring { display: grid; place-items: center; width: 58px; height: 58px; margin: 4px 2px 4px 4px; border: 1px solid #b9d5ff; border-radius: 50%; color: #1677ff; background: #f1f7ff; font-size: 25px; }.scan-intro h2 { margin: 0 0 7px; font-size: 19px; line-height: 1.35; }.scan-intro p { margin: 0; color: #7b8799; font-size: 13px; }.scan-input :deep(input) { height: 50px; font-size: 16px; }.scan-input :deep(.ant-input-group-addon .ant-btn) { height: 50px; font-weight: 600; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 15px; padding-left: 2px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint span { margin: 0 2px; color: #b7c0cd; }.config-form { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: 18px; }.config-form :deep(.ant-form-item) { margin-bottom: 14px; }.full-width { width: 100%; }
+.device-state { justify-content: space-between; margin-bottom: 20px; }.online-badge { display: inline-flex; align-items: center; gap: 10px; min-height: 24px; color: #16835b; white-space: nowrap; }.muted, .sub-text { color: #8793a5; font-size: 12px; }.status-list { border-top: 1px solid #edf0f5; }.status-list div { display: flex; justify-content: space-between; gap: 12px; padding: 13px 0; border-bottom: 1px solid #edf0f5; font-size: 13px; }.status-list span { color: #7b8799; }.status-list strong { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }.sync-box { display: flex; gap: 10px; margin-top: 20px; padding: 12px; border-radius: 7px; color: #16835b; background: #effaf5; }.sync-box div { display: flex; flex-direction: column; gap: 3px; }.sync-box span { color: #5f8b76; font-size: 12px; }.record-count { margin-left: 4px; color: #9aa5b5; font-size: 12px; font-weight: 400; }.sub-text { display: block; margin-top: 3px; }
+@media (max-width: 900px) { .metric-grid { grid-template-columns: repeat(2, 1fr); }.main-grid { grid-template-columns: 1fr; } }
+@media (max-width: 600px) { .scan-page { padding: 14px; }.page-header { display: block; }.header-status { margin-top: 14px; }.metric-grid { grid-template-columns: 1fr; }.config-form { grid-template-columns: 1fr; } h1 { font-size: 23px; } }
+</style>
diff --git a/src/views/mes/pro/scanEvent/index.vue b/src/views/mes/pro/scanEvent/index.vue
new file mode 100644
index 0000000..7042afc
--- /dev/null
+++ b/src/views/mes/pro/scanEvent/index.vue
@@ -0,0 +1,210 @@
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue';
+
+import { message } from 'ant-design-vue';
+import { IconifyIcon } from '@vben/icons';
+import { gsap } from 'gsap';
+
+import {
+  getLatestScanEvent,
+  getScanDashboard,
+  getScanEventPage,
+  inboundScanEvent,
+  type MesProScanEventApi,
+} from '#/api/mes/pro/scanEvent';
+
+interface ScanRecord extends MesProScanEventApi.ScanEvent {
+  key: number;
+}
+
+const code = ref('');
+const deviceCode = ref('');
+const lineCode = ref('');
+const locationId = ref<number>();
+const areaId = ref<number>();
+const loading = ref(false);
+const refreshing = ref(false);
+const records = ref<ScanRecord[]>([]);
+const dashboard = ref<MesProScanEventApi.Dashboard>({});
+let timer: ReturnType<typeof setInterval> | undefined;
+
+const latest = computed(() => dashboard.value.latest);
+const successCount = computed(() => dashboard.value.success ?? records.value.filter((item) => item.status === 'SUCCESS').length);
+const failedCount = computed(() => dashboard.value.failed ?? records.value.filter((item) => item.status === 'FAILED').length);
+const isReady = computed(() => Boolean(deviceCode.value.trim() && locationId.value && areaId.value));
+const pageRoot = ref<HTMLElement>();
+let animationContext: gsap.Context | undefined;
+let feedbackTimeline: gsap.core.Timeline | undefined;
+
+async function refresh() {
+  refreshing.value = true;
+  try {
+    const [summary, page] = await Promise.all([
+      getScanDashboard(),
+      getScanEventPage({ pageNo: 1, pageSize: 20, deviceCode: deviceCode.value.trim() || undefined }),
+    ]);
+    dashboard.value = summary;
+    records.value = (page.list ?? []).map((item, index) => ({ ...item, key: item.id ?? index }));
+  } finally {
+    refreshing.value = false;
+  }
+}
+
+async function submitScan() {
+  const bagCode = code.value.trim();
+  if (!bagCode || !deviceCode.value.trim()) {
+    message.warning('璇峰厛濉啓璁惧缂栫爜锛屽啀鎵弿琚嬬爜');
+    return;
+  }
+  if (!locationId.value || !areaId.value) {
+    message.warning('璇峰厛閰嶇疆 WIP 鏆傚瓨搴撳尯鍜屽簱浣�');
+    return;
+  }
+  loading.value = true;
+  try {
+    await inboundScanEvent({
+      eventId: `${deviceCode.value.trim()}-${Date.now()}`,
+      bagCode,
+      quantity: 1,
+      deviceCode: deviceCode.value.trim(),
+      lineCode: lineCode.value.trim() || undefined,
+      locationId: locationId.value,
+      areaId: areaId.value,
+    });
+    message.success(`琚嬬爜 ${bagCode} 宸插畬鎴愬叆搴揱);
+    if (animationContext && pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+      feedbackTimeline?.kill();
+      feedbackTimeline = gsap.timeline();
+      feedbackTimeline
+        .to('.scan-ring', { scale: 1.16, duration: 0.18, ease: 'power2.out' })
+        .to('.scan-ring', { scale: 1, duration: 0.5, ease: 'elastic.out(1, 0.45)' })
+        .to('.scan-card', { boxShadow: '0 0 0 3px #b7ebd2, 0 12px 30px #20b87820', duration: 0.2 }, 0)
+        .to('.scan-card', { boxShadow: '0 3px 12px #243b5310', duration: 0.65 });
+    }
+    code.value = '';
+    await refresh();
+  } catch (error) {
+    const response = error as { message?: string; msg?: string };
+    message.error(response.message ?? response.msg ?? '鎵爜鍏ュ簱澶辫触锛岃鏌ョ湅浜嬩欢璁板綍');
+    await refresh();
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function refreshLatest() {
+  if (deviceCode.value.trim()) await getLatestScanEvent(deviceCode.value.trim());
+  await refresh();
+}
+
+onMounted(async () => {
+  await nextTick();
+  if (pageRoot.value) {
+    animationContext = gsap.context(() => {
+      const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+      gsap.from('.page-header, .metric-card, .scan-card, .status-card, .records-card', {
+        autoAlpha: reduceMotion ? 1 : 0,
+        y: reduceMotion ? 0 : 14,
+        duration: reduceMotion ? 0 : 0.45,
+        stagger: reduceMotion ? 0 : 0.06,
+        ease: 'power2.out',
+      });
+      if (!reduceMotion) {
+        gsap.to('.status-dot', {
+          scale: 1.12,
+          boxShadow: '0 0 0 5px #d9f7e9',
+          duration: 1.2,
+          repeat: -1,
+          yoyo: true,
+          ease: 'sine.inOut',
+        });
+        gsap.to('.scan-ring', {
+          boxShadow: '0 0 0 10px #eaf3ff, 0 0 24px #1677ff30',
+          duration: 1.8,
+          repeat: -1,
+          yoyo: true,
+          ease: 'sine.inOut',
+        });
+      }
+    }, pageRoot.value);
+  }
+  await refresh();
+  timer = setInterval(refreshLatest, 15000);
+});
+
+onBeforeUnmount(() => {
+  if (timer) clearInterval(timer);
+});
+
+onUnmounted(() => {
+  feedbackTimeline?.kill();
+  animationContext?.revert();
+});
+</script>
+
+<template>
+  <div ref="pageRoot" class="scan-page">
+    <header class="page-header">
+      <div>
+        <div class="eyebrow">MES / 涓�琚嬩竴鐮佺鐞�</div>
+        <h1>鐢熶骇绾挎壂鐮佸叆搴�</h1>
+        <p>瀹炴椂鎺ユ敹杈圭紭璁惧瑙g爜缁撴灉锛岃嚜鍔ㄧ櫥璁板叆搴撳苟鍚屾 WIP 鏆傚瓨搴撳瓨</p>
+      </div>
+      <div class="header-status"><span class="status-dot" aria-hidden="true" /><span>瀹炴椂鐩戞帶涓�</span> <a-button type="text" :loading="refreshing" aria-label="鍒锋柊鏁版嵁" @click="refresh"><IconifyIcon icon="ant-design:reload-outlined" /></a-button></div>
+    </header>
+
+    <section class="metric-grid" aria-label="鍏ュ簱缁熻">
+      <div class="metric-card"><div class="metric-icon blue"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><span>绱鎵爜浜嬩欢</span><strong>{{ dashboard.total ?? 0 }}</strong><small>鎸佺画鎺ユ敹</small></div></div>
+      <div class="metric-card"><div class="metric-icon green"><IconifyIcon icon="ant-design:check-circle-outlined" /></div><div><span>鎴愬姛鍏ュ簱</span><strong>{{ successCount }}</strong><small>宸插啓鍏ュ簱瀛樹簨鍔�</small></div></div>
+      <div class="metric-card"><div class="metric-icon red"><IconifyIcon icon="ant-design:warning-outlined" /></div><div><span>澶辫触浜嬩欢</span><strong>{{ failedCount }}</strong><small>闇�鍏虫敞寮傚父璁板綍</small></div></div>
+      <div class="metric-card"><div class="metric-icon orange"><IconifyIcon icon="ant-design:inbox-outlined" /></div><div><span>鏈�杩戝叆搴撹鐮�</span><strong class="text-value">{{ latest?.bagCode ?? '-' }}</strong><small>{{ latest?.eventTime ?? '鏆傛棤璁板綍' }}</small></div></div>
+    </section>
+
+    <section class="main-grid">
+      <a-card class="scan-card" :bordered="false">
+        <template #title><div class="card-title"><IconifyIcon icon="ant-design:scan-outlined" />鎵爜鍏ュ簱 <a-tag color="blue">鑷姩妯″紡</a-tag></div></template>
+        <div class="scan-panel">
+          <div class="scan-intro"><div class="scan-ring"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><h2>绛夊緟鎵爜</h2><p>灏嗚鐮佸鍑嗗伐涓氱浉鏈猴紝鎴栦娇鐢ㄦ壂鐮佹灙鎵弿鍚庡洖杞︾‘璁�</p></div></div>
+          <a-input-search v-model:value="code" class="scan-input" size="large" enter-button="纭鍏ュ簱" :loading="loading" :disabled="loading" placeholder="璇锋壂鎻忚鐮侊紝鎵爜鏋洖杞﹀悗鑷姩鍏ュ簱" @search="submitScan" />
+          <div class="scan-hint"><IconifyIcon icon="ant-design:clock-circle-outlined" /> 鏈嶅姟绔褰曞噯纭叆搴撴椂闂� <span>路</span> <IconifyIcon icon="ant-design:inbox-outlined" /> 姣忚鏁伴噺鎸� 1 璁�</div>
+        </div>
+        <a-divider />
+        <a-form layout="vertical" class="config-form">
+          <a-form-item label="璁惧缂栫爜" required><a-input v-model:value="deviceCode" prefix="璁惧" placeholder="渚嬪锛欳AM-LINE-01" /></a-form-item>
+          <a-form-item label="浜х嚎缂栫爜"><a-input v-model:value="lineCode" placeholder="渚嬪锛歀INE-A" /></a-form-item>
+          <a-form-item label="WIP 鏆傚瓨搴撳尯" required><a-input-number v-model:value="locationId" class="full-width" :min="1" placeholder="搴撳尯缂栧彿" /></a-form-item>
+          <a-form-item label="WIP 鏆傚瓨搴撲綅" required><a-input-number v-model:value="areaId" class="full-width" :min="1" placeholder="搴撲綅缂栧彿" /></a-form-item>
+        </a-form>
+      </a-card>
+
+      <a-card class="status-card" :bordered="false">
+        <template #title><div class="card-title"><ApiOutlined />閲囬泦璁惧鐘舵��</div></template>
+        <div class="device-state"><span class="online-badge"><span class="status-dot" aria-hidden="true" /><span>{{ isReady ? '璁惧宸插氨缁�' : '绛夊緟閰嶇疆' }}</span></span><span class="muted">15 绉掕嚜鍔ㄥ埛鏂�</span></div>
+        <div class="status-list"><div><span>褰撳墠璁惧</span><strong>{{ deviceCode || '鏈厤缃�' }}</strong></div><div><span>鐢熶骇浜х嚎</span><strong>{{ lineCode || '鏈厤缃�' }}</strong></div><div><span>鏆傚瓨浣嶇疆</span><strong>{{ locationId && areaId ? `${locationId} 鍖� / ${areaId} 浣峘 : '鏈厤缃�' }}</strong></div><div><span>鏈�杩戝叆搴撴椂闂�</span><strong>{{ latest?.eventTime || '-' }}</strong></div><div><span>鏈�杩戠敓浜ф壒娆�</span><strong>{{ latest?.batchCode || '-' }}</strong></div></div>
+        <div class="sync-box"><IconifyIcon icon="ant-design:check-circle-outlined" /><div><strong>浠撳偍绯荤粺鍚屾姝e父</strong><span>鎵爜浜嬩欢涓庡簱瀛樹簨鍔″疄鏃跺啓鍏�</span></div></div>
+      </a-card>
+    </section>
+
+    <a-card class="records-card" :bordered="false">
+      <template #title><div class="card-title"><IconifyIcon icon="ant-design:clock-circle-outlined" />鏈�杩戞壂鐮佷簨浠� <span class="record-count">鏈�杩� 20 鏉�</span></div></template>
+      <a-table :data-source="records" :pagination="false" :scroll="{ x: 1050 }" row-key="key" size="middle">
+        <a-table-column key="bagCode" title="琚嬬爜" data-index="bagCode" /><a-table-column key="batchCode" title="鐢熶骇鎵规" data-index="batchCode" /><a-table-column key="deviceCode" title="璁惧 / 浜х嚎"><template #default="{ record }">{{ record.deviceCode || '-' }}<span class="sub-text">{{ record.lineCode || '-' }}</span></template></a-table-column>
+        <a-table-column key="status" title="鐘舵��"><template #default="{ record }"><a-tag :color="record.status === 'SUCCESS' ? 'success' : 'error'">{{ record.status === 'SUCCESS' ? '鎴愬姛鍏ュ簱' : '澶辫触' }}</a-tag></template></a-table-column><a-table-column key="errorCode" title="閿欒鐮�" data-index="errorCode" /><a-table-column key="errorMessage" title="澶辫触鍘熷洜" data-index="errorMessage" /><a-table-column key="eventTime" title="澶勭悊鏃堕棿" data-index="eventTime" /><a-table-column key="transactionId" title="搴撳瓨浜嬪姟" data-index="transactionId" />
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<style scoped>
+.scan-page { min-height: 100%; padding: 24px; background: #f4f7fb; color: #172033; }
+.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 22px; }
+.eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
+h1 { margin: 6px 0; font-size: 28px; font-weight: 700; } .page-header p { margin: 0; color: #718096; }
+.header-status, .device-state { display: flex; align-items: center; gap: 10px; color: #16835b; font-size: 13px; font-weight: 600; }.header-status :deep(.ant-btn) { color: #607089; }
+.status-dot { display: inline-block; flex: 0 0 8px; width: 8px; height: 8px; margin: 0 2px 0 1px; border-radius: 50%; background: #20b878; box-shadow: 0 0 0 4px #d9f7e9; transform-origin: center; }
+.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 18px; }.metric-card { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px; background: #fff; border: 1px solid #e8edf5; border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.metric-card > div:last-child { min-width: 0; padding-top: 1px; }.metric-icon { flex: 0 0 42px; display: grid; place-items: center; width: 42px; height: 42px; margin-top: 1px; border-radius: 9px; font-size: 20px; }.metric-icon.blue { color: #1677ff; background: #eaf3ff; }.metric-icon.green { color: #17a673; background: #e6f8f0; }.metric-icon.red { color: #e05252; background: #fff0f0; }.metric-icon.orange { color: #d58a17; background: #fff6df; }.metric-card span, .metric-card small { display: block; color: #7b8799; font-size: 12px; }.metric-card strong { display: block; margin: 4px 0; font-size: 25px; line-height: 1.1; }.metric-card .text-value { overflow: hidden; max-width: 180px; text-overflow: ellipsis; white-space: nowrap; font-size: 18px; }
+.main-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .8fr); gap: 18px; margin-bottom: 18px; }.scan-card, .status-card, .records-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 10px; min-height: 24px; font-weight: 700; }.card-title :deep(.iconify) { flex: 0 0 auto; margin-top: 1px; }.card-title :deep(.anticon) { color: #1677ff; }.card-title .ant-tag { margin-left: 4px; font-weight: 500; }.scan-panel { padding: 8px 0 4px; }.scan-intro { display: flex; align-items: center; gap: 16px; margin: 4px 0 22px; }.scan-ring { display: grid; place-items: center; width: 58px; height: 58px; margin: 4px 2px 4px 4px; border: 1px solid #b9d5ff; border-radius: 50%; color: #1677ff; background: #f1f7ff; font-size: 25px; }.scan-intro h2 { margin: 0 0 7px; font-size: 19px; line-height: 1.35; }.scan-intro p { margin: 0; color: #7b8799; font-size: 13px; }.scan-input :deep(input) { height: 50px; font-size: 16px; }.scan-input :deep(.ant-input-group-addon .ant-btn) { height: 50px; font-weight: 600; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 15px; padding-left: 2px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint span { margin: 0 2px; color: #b7c0cd; }.config-form { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: 18px; }.config-form :deep(.ant-form-item) { margin-bottom: 14px; }.full-width { width: 100%; }
+.device-state { justify-content: space-between; margin-bottom: 20px; }.online-badge { display: inline-flex; align-items: center; gap: 10px; min-height: 24px; color: #16835b; white-space: nowrap; }.muted, .sub-text { color: #8793a5; font-size: 12px; }.status-list { border-top: 1px solid #edf0f5; }.status-list div { display: flex; justify-content: space-between; gap: 12px; padding: 13px 0; border-bottom: 1px solid #edf0f5; font-size: 13px; }.status-list span { color: #7b8799; }.status-list strong { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }.sync-box { display: flex; gap: 10px; margin-top: 20px; padding: 12px; border-radius: 7px; color: #16835b; background: #effaf5; }.sync-box div { display: flex; flex-direction: column; gap: 3px; }.sync-box span { color: #5f8b76; font-size: 12px; }.record-count { margin-left: 4px; color: #9aa5b5; font-size: 12px; font-weight: 400; }.sub-text { display: block; margin-top: 3px; }
+@media (max-width: 900px) { .metric-grid { grid-template-columns: repeat(2, 1fr); }.main-grid { grid-template-columns: 1fr; } }
+@media (max-width: 600px) { .scan-page { padding: 14px; }.page-header { display: block; }.header-status { margin-top: 14px; }.metric-grid { grid-template-columns: 1fr; }.config-form { grid-template-columns: 1fr; } h1 { font-size: 23px; } }
+</style>

--
Gitblit v1.9.3