xiaoyi
2026-10-28 8afaccb9619d058cc90f974d966e8f884a9113d6
feat(chdl): 长辉品牌登录页 + 数采监控页面 + 接口地址切到长辉环境

- 登录页/品牌:新增 CHDL logo、auth-scene 场景组件、登录页文案(中/英)、company-config 接口调整
- 数采监控:新增 mes/dv/collect 页面(KPI 卡片 + 筛选工具栏 + 表格 + 详情弹窗)
- 环境:.env.production / company.config.ts 接口地址由东恒切到长辉
- chore: .gitignore 忽略 dist*.zip 构建产物
已添加8个文件
已修改7个文件
2201 ■■■■■ 文件已修改
.env.production 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
company.config.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/logos/CHDL.svg 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/dv/collect/index.ts 167 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/company-config/index.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/auth-scene.vue 530 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/auth.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/locales/langs/en-US/authentication.json 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/locales/langs/zh-CN/authentication.json 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/basic/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/authentication/login.vue 342 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/collect/data.ts 263 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/collect/index.vue 483 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/collect/modules/detail.vue 298 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env.production
@@ -1,11 +1,11 @@
VITE_COMPANY=TEST
VITE_COMPANY=CHDL
VITE_BASE=/
# è¯·æ±‚路径
VITE_BASE_URL=http://36.213.90.123:9501
# è¯·æ±‚路径(长辉 chdl:nginx 9006 åä»£åŽç«¯ 9005)
VITE_BASE_URL=http://101.245.92.159:9006
# æŽ¥å£åœ°å€
VITE_GLOB_API_URL=http://36.213.90.123:9501/admin-api
VITE_GLOB_API_URL=http://101.245.92.159:9006/admin-api
# æ–‡ä»¶ä¸Šä¼ ç±»åž‹ï¼šserver - åŽç«¯ä¸Šä¼ ï¼Œ client - å‰ç«¯ç›´è¿žä¸Šä¼ ï¼Œä»…支持S3服务
VITE_UPLOAD_TYPE=server
.gitignore
@@ -30,4 +30,7 @@
# Cache
.cache/
.temp/
.temp/
# Build archives
dist*.zip
company.config.ts
@@ -66,10 +66,10 @@
    apiUrl: 'http://36.213.150.237:9006/admin-api',
  },
  CHDL: {
    name: '长辉电力',
    logo: '/logos/YZFXLogo.png',
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9004/admin-api',
    name: '长辉电力工业智造平台',
    logo: '/logos/CHDL.svg',
    favicon: '/logos/CHDL.svg',
    apiUrl: 'http://101.245.92.159:9006/admin-api',
  },
  HM: {
    name: '惠牧',
public/logos/CHDL.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="长辉电力">
  <title>长辉电力</title>
  <defs>
    <linearGradient id="chdl-shell" x1="7" y1="4" x2="41" y2="45" gradientUnits="userSpaceOnUse">
      <stop offset="0" stop-color="#1E5A8F"/>
      <stop offset="0.55" stop-color="#123A5C"/>
      <stop offset="1" stop-color="#0A2135"/>
    </linearGradient>
    <linearGradient id="chdl-bolt" x1="24" y1="9" x2="24" y2="39" gradientUnits="userSpaceOnUse">
      <stop offset="0" stop-color="#A8EEFF"/>
      <stop offset="0.5" stop-color="#4FC8FF"/>
      <stop offset="1" stop-color="#1E9BEE"/>
    </linearGradient>
    <radialGradient id="chdl-sheen" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(16 12) rotate(48) scale(30)">
      <stop offset="0" stop-color="#FFFFFF" stop-opacity="0.22"/>
      <stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
    </radialGradient>
  </defs>
  <!-- å·¥ä¸šå…­è¾¹å½¢å¤–壳 -->
  <path d="M24 3 L42.19 13.5 V34.5 L24 45 L5.81 34.5 V13.5 Z" fill="url(#chdl-shell)"/>
  <path d="M24 3 L42.19 13.5 V34.5 L24 45 L5.81 34.5 V13.5 Z" fill="url(#chdl-sheen)"/>
  <path d="M24 3 L42.19 13.5 V34.5 L24 45 L5.81 34.5 V13.5 Z" fill="none" stroke="#3FC8FF" stroke-opacity="0.6" stroke-width="1.2"/>
  <!-- å†…圈刻度环 -->
  <path d="M24 7 L38.72 15.5 V32.5 L24 41 L9.28 32.5 V15.5 Z" fill="none" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1"/>
  <!-- ç”µåŠ›é—ªç”µ -->
  <path d="M27.6 8.4 L15.6 26.4 H21.9 L20.2 39.6 L32.4 21.6 H26.1 Z" fill="url(#chdl-bolt)"/>
</svg>
src/api/mes/dv/collect/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,167 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MesCollectApi {
  /** æ•°é‡‡è®¾å¤‡çŠ¶æ€ */
  export interface CollectDevice {
    machineryId?: number; // è®¾å¤‡å°è´¦ç¼–号
    machineryCode?: string; // è®¾å¤‡ç¼–码
    machineryName?: string; // è®¾å¤‡åç§°
    workstationId?: number; // å·¥ä½œç«™ç¼–号
    workstationName?: string; // å·¥ä½œç«™åç§°
    collectDeviceCode?: string; // æ•°é‡‡è®¾å¤‡ç¼–号
    collectEnabled?: boolean; // æ˜¯å¦å¯ç”¨æ•°é‡‡
    lastCollectTime?: string; // æœ€è¿‘采集时间
    online?: boolean; // æ˜¯å¦åœ¨çº¿ï¼ˆ5 åˆ†é’Ÿå†…有数据)
    sts?: number; // è¿è¡ŒçŠ¶æ€ï¼š1待机 2生产
    stsName?: string; // è¿è¡ŒçŠ¶æ€åç§°
    opm?: number; // è¿è¡Œæ¨¡å¼
    opmName?: string; // è¿è¡Œæ¨¡å¼åç§°
    ot?: number; // æ²¹æ¸©
    temps?: string; // å„段温度 JSON
    taskId?: number; // ç”Ÿäº§ä»»åŠ¡ç¼–å·
    taskCode?: string; // ç”Ÿäº§ä»»åŠ¡ç¼–ç 
    taskName?: string; // ç”Ÿäº§ä»»åŠ¡åç§°
    workOrderId?: number; // ç”Ÿäº§å·¥å•编号
    workOrderCode?: string; // ç”Ÿäº§å·¥å•编码
    activeAlarmCount?: number; // æœªæ¶ˆé™¤æŠ¥è­¦æ•°é‡
  }
  /** æ•°é‡‡å®žæ—¶æ•°æ® */
  export interface CollectRealtime {
    id?: number; // ç¼–号
    machineryId?: number; // è®¾å¤‡å°è´¦ç¼–号
    deviceCode?: string; // æ•°é‡‡è®¾å¤‡ç¼–号
    taskId?: number; // ç”Ÿäº§ä»»åŠ¡ç¼–å·
    workOrderId?: number; // ç”Ÿäº§å·¥å•编号
    collectTime?: string; // é‡‡é›†æ—¶é—´
    sts?: number; // è¿è¡ŒçŠ¶æ€
    stsName?: string; // è¿è¡ŒçŠ¶æ€åç§°
    opm?: number; // è¿è¡Œæ¨¡å¼
    opmName?: string; // è¿è¡Œæ¨¡å¼åç§°
    asts?: number; // é™„加状态
    ot?: number; // æ²¹æ¸©
    temps?: string; // å„段温度 JSON
    dataJson?: string; // å®Œæ•´æ•°æ® JSON
  }
  /** æ•°é‡‡æŠ¥è­¦ */
  export interface CollectAlarm {
    id?: number; // ç¼–号
    machineryId?: number; // è®¾å¤‡å°è´¦ç¼–号
    deviceCode?: string; // æ•°é‡‡è®¾å¤‡ç¼–号
    taskId?: number; // ç”Ÿäº§ä»»åŠ¡ç¼–å·
    workOrderId?: number; // ç”Ÿäº§å·¥å•编号
    wmId?: number; // æŠ¥è­¦æ ‡è¯†ï¼š2产生 0消失
    wmMsg?: string; // æŠ¥è­¦å†…容
    wmTime?: string; // æŠ¥è­¦æ—¶é—´
    status?: number; // çŠ¶æ€ï¼š0未消除 1已消除
    statusName?: string; // çŠ¶æ€åç§°
    clearTime?: string; // æ¶ˆé™¤æ—¶é—´
    collectTime?: string; // æŠ¥æ–‡é‡‡é›†æ—¶é—´
    dataJson?: string; // å®Œæ•´æ•°æ® JSON
  }
  /** æ•°é‡‡å‘¨æœŸè¿‡ç¨‹æ•°æ® */
  export interface CollectCycle {
    id?: number;
    machineryId?: number;
    deviceCode?: string;
    cycleNo?: number;
    collectTime?: string;
    dataJson?: string;
  }
  /** æ•°é‡‡å‚数变更日志 */
  export interface CollectOperateLog {
    id?: number;
    machineryId?: number;
    deviceCode?: string;
    varId?: string;
    oldValue?: string;
    newValue?: string;
    modifyTime?: string;
    collectTime?: string;
    dataJson?: string;
  }
  /** æ•°é‡‡å·¥è‰ºå‚æ•°å¿«ç…§ */
  export interface CollectTech {
    id?: number;
    machineryId?: number;
    deviceCode?: string;
    collectTime?: string;
    dataJson?: string;
  }
}
/** æŸ¥è¯¢æ•°é‡‡è®¾å¤‡çŠ¶æ€åˆ—è¡¨ */
export function getCollectDeviceList() {
  return requestClient.get<MesCollectApi.CollectDevice[]>(
    '/mes/collect/device/list',
  );
}
/** æŸ¥è¯¢è®¾å¤‡æœ€æ–°å®žæ—¶çŠ¶æ€ */
export function getCollectRealtime(deviceCode: string) {
  return requestClient.get<MesCollectApi.CollectRealtime>(
    '/mes/collect/realtime/get',
    { params: { deviceCode } },
  );
}
/** åˆ†é¡µæŸ¥è¯¢å®žæ—¶çŠ¶æ€åŽ†å² */
export function getCollectRealtimeHistoryPage(params: PageParam) {
  return requestClient.get<PageResult<MesCollectApi.CollectRealtime>>(
    '/mes/collect/realtime/history-page',
    { params },
  );
}
/** åˆ†é¡µæŸ¥è¯¢å‘¨æœŸè¿‡ç¨‹æ•°æ® */
export function getCollectCyclePage(params: PageParam) {
  return requestClient.get<PageResult<MesCollectApi.CollectCycle>>(
    '/mes/collect/cycle/page',
    { params },
  );
}
/** æŸ¥è¯¢å‘¨æœŸè¿‡ç¨‹æ•°æ®æ˜Žç»† */
export function getCollectCycle(id: number) {
  return requestClient.get<MesCollectApi.CollectCycle>(
    '/mes/collect/cycle/get',
    { params: { id } },
  );
}
/** æŸ¥è¯¢è®¾å¤‡æœ€æ–°å·¥è‰ºå‚æ•°å¿«ç…§ */
export function getCollectTech(deviceCode: string) {
  return requestClient.get<MesCollectApi.CollectTech>(
    '/mes/collect/tech/get',
    { params: { deviceCode } },
  );
}
/** åˆ†é¡µæŸ¥è¯¢å‚数变更日志 */
export function getCollectOperateLogPage(params: PageParam) {
  return requestClient.get<PageResult<MesCollectApi.CollectOperateLog>>(
    '/mes/collect/operate-log/page',
    { params },
  );
}
/** åˆ†é¡µæŸ¥è¯¢æŠ¥è­¦è®°å½• */
export function getCollectAlarmPage(params: PageParam) {
  return requestClient.get<PageResult<MesCollectApi.CollectAlarm>>(
    '/mes/collect/alarm/page',
    { params },
  );
}
/** äººå·¥ç¡®è®¤æ¶ˆé™¤æŠ¥è­¦ */
export function ackCollectAlarm(id: number) {
  return requestClient.put('/mes/collect/alarm/ack', null, {
    params: { id },
  });
}
src/api/system/company-config/index.ts
@@ -12,11 +12,31 @@
  }
}
/** èŽ·å¾—å…¬å¸å“ç‰Œä¿¡æ¯ï¼ˆå…ç™»å½•å…¬å¼€ï¼Œç™»å½•é¡µ / åº”用启动时使用) */
/**
 * èŽ·å¾—å…¬å¸å“ç‰Œä¿¡æ¯ï¼ˆå…ç™»å½•å…¬å¼€ï¼Œç™»å½•é¡µ / åº”用启动时使用)
 *
 * è¯¥æŽ¥å£åªæ˜¯å±•示增强项:后端未部署该模块、或返回未登录时,
 * éƒ½ä¸åº”打断登录流程。因此使用 raw æ¨¡å¼è‡ªè¡Œåˆ¤æ–­ä¸šåŠ¡ç ï¼Œ
 * é¿å…èµ°å…¨å±€é”™è¯¯æ‹¦æˆªå™¨â€”—否则接口返回 401 ä¼šè¢«å½“成登录态失效,
 * è§¦å‘ refreshToken å¤±è´¥å¹¶å¼¹å‡ºã€Œå†…部服务器错误」提示。
 */
export async function getCompanyConfig() {
  return requestClient.get<SystemCompanyConfigApi.CompanyConfig | null>(
    '/system/company-config/get',
  );
  try {
    const response = await requestClient.get<{
      code: number;
      data: null | SystemCompanyConfigApi.CompanyConfig;
    }>('/system/company-config/get', {
      responseReturn: 'raw',
    });
    const body = response?.data;
    if (body && body.code === 0) {
      return body.data ?? null;
    }
    return null;
  } catch {
    // åŽç«¯ä¸å¯ç”¨æˆ–未配置时,静默回退到编译期默认品牌
    return null;
  }
}
/** ä¿å­˜å…¬å¸å“ç‰Œä¿¡æ¯ */
src/layouts/auth-scene.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,530 @@
<script lang="ts" setup>
import { computed } from 'vue';
import {
  AuthenticationColorToggle,
  Copyright,
  LanguageToggle,
  ThemeToggle,
} from '@vben/layouts';
import { $t } from '@vben/locales';
import { preferences } from '@vben/preferences';
interface Props {
  appName?: string;
  logo?: string;
  pageDescription?: string;
  pageTitle?: string;
}
defineOptions({ name: 'AuthScene' });
const props = withDefaults(defineProps<Props>(), {
  appName: '',
  logo: '',
  pageDescription: '',
  pageTitle: '',
});
/**
 * ä»ªè¡¨åˆ»åº¦ï¼š240° æ‰«æŽ å†…均布 61 æ ¹ï¼Œæ¯ 5 æ ¹ä¸ºä¸»åˆ»åº¦ã€‚
 * è§’度从 -210° èµ·ç®—(SVG ä¸­é¡ºæ—¶é’ˆä¸ºæ­£ï¼‰ï¼Œä¸Žä¸‹æ–¹åœ†å¼§è·¯å¾„保持一致。
 */
const gaugeTicks = Array.from({ length: 61 }, (_, index) => {
  const angle = -210 + index * 4;
  const major = index % 5 === 0;
  return { angle, length: major ? 13 : 6 };
});
const showBrand = computed(() => Boolean(props.logo || props.appName));
</script>
<template>
  <div class="auth-scene">
    <!-- ==================== å·¦ï¼šå·¥ä¸šä»ªè¡¨å°é¢ ==================== -->
    <aside class="auth-scene__face">
      <div class="auth-scene__grid" aria-hidden="true"></div>
      <div class="auth-scene__glow" aria-hidden="true"></div>
      <svg
        class="auth-scene__gauge"
        viewBox="0 0 400 400"
        aria-hidden="true"
        focusable="false"
      >
        <!-- å¤–圈轨道 -->
        <path
          class="gauge__track"
          d="M 70.1 275 A 150 150 0 1 1 329.9 275"
        />
        <!-- å†…圈虚线轨道 -->
        <path
          class="gauge__track gauge__track--dashed"
          d="M 96.1 260 A 120 120 0 1 1 303.9 260"
        />
        <!-- å·²è¯»æ•°å¼§ -->
        <path
          class="gauge__value"
          d="M 70.1 275 A 150 150 0 0 1 311.5 99.6"
        />
        <!-- åˆ»åº¦ -->
        <g>
          <line
            v-for="tick in gaugeTicks"
            :key="tick.angle"
            :class="[
              'gauge__tick',
              { 'gauge__tick--major': tick.length > 10 },
            ]"
            x1="200"
            y1="50"
            x2="200"
            :y2="50 + tick.length"
            :transform="`rotate(${tick.angle} 200 200)`"
          />
        </g>
        <!-- æŒ‡é’ˆ -->
        <g class="gauge__needle">
          <line x1="200" y1="200" x2="200" y2="92" />
        </g>
        <circle class="gauge__hub" cx="200" cy="200" r="7" />
        <circle class="gauge__hub-dot" cx="200" cy="200" r="2.5" />
      </svg>
      <header v-if="showBrand" class="auth-scene__brand">
        <img
          v-if="logo"
          :key="logo"
          :alt="appName"
          :src="logo"
          class="auth-scene__logo"
        />
        <p v-if="appName" class="auth-scene__name">{{ appName }}</p>
      </header>
      <div class="auth-scene__intro">
        <p class="auth-scene__eyebrow">
          <span class="auth-scene__eyebrow-rule" aria-hidden="true"></span>
          {{ $t('authentication.loginEyebrow') }}
        </p>
        <h1 v-if="pageTitle" class="auth-scene__title">{{ pageTitle }}</h1>
        <p v-if="pageDescription" class="auth-scene__desc">
          {{ pageDescription }}
        </p>
      </div>
    </aside>
    <!-- ==================== å³ï¼šç™»å½•面板 ==================== -->
    <section class="auth-scene__panel">
      <div class="auth-scene__toolbar">
        <AuthenticationColorToggle />
        <LanguageToggle />
        <ThemeToggle />
      </div>
      <div class="auth-scene__form">
        <span class="auth-scene__corner auth-scene__corner--tl" aria-hidden="true"></span>
        <span class="auth-scene__corner auth-scene__corner--tr" aria-hidden="true"></span>
        <span class="auth-scene__corner auth-scene__corner--bl" aria-hidden="true"></span>
        <span class="auth-scene__corner auth-scene__corner--br" aria-hidden="true"></span>
        <RouterView v-slot="{ Component, route }">
          <Transition appear mode="out-in" name="slide-up">
            <KeepAlive :include="['Login']">
              <component
                :is="Component"
                :key="route.fullPath"
                class="side-content w-full"
                data-side="bottom"
              />
            </KeepAlive>
          </Transition>
        </RouterView>
      </div>
      <div class="auth-scene__copyright">
        <slot name="copyright">
          <Copyright
            v-if="preferences.copyright.enable"
            v-bind="preferences.copyright"
          />
        </slot>
      </div>
    </section>
  </div>
</template>
<style scoped>
/* ==================== éª¨æž¶ ==================== */
.auth-scene {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(24rem, 0.85fr);
  min-height: 100svh;
  overflow-x: hidden;
  background: hsl(var(--background));
}
/* ==================== å·¦ï¼šä»ªè¡¨å°é¢ ==================== */
.auth-scene__face {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  overflow: hidden;
  padding: clamp(2rem, 3.2vw, 3.25rem) clamp(2rem, 4vw, 4.5rem);
  isolation: isolate;
  color: #e8eef7;
  background:
    radial-gradient(
      115% 85% at 6% 4%,
      hsl(var(--primary) / 0.22) 0%,
      transparent 58%
    ),
    linear-gradient(158deg, #111823 0%, #090d13 55%, #05070a 100%);
}
/* è“å›¾ç½‘æ ¼ */
.auth-scene__grid {
  position: absolute;
  z-index: -2;
  inset: 0;
  background-image:
    linear-gradient(to right, rgb(255 255 255 / 0.055) 1px, transparent 1px),
    linear-gradient(to bottom, rgb(255 255 255 / 0.055) 1px, transparent 1px),
    linear-gradient(to right, rgb(255 255 255 / 0.025) 1px, transparent 1px),
    linear-gradient(to bottom, rgb(255 255 255 / 0.025) 1px, transparent 1px);
  background-size:
    96px 96px,
    96px 96px,
    24px 24px,
    24px 24px;
  mask-image: radial-gradient(135% 105% at 18% 12%, #000 20%, transparent 76%);
}
/* å†·å…‰æ™• */
.auth-scene__glow {
  position: absolute;
  z-index: -1;
  top: 6%;
  right: -16%;
  width: 44rem;
  height: 44rem;
  border-radius: 9999px;
  background: radial-gradient(
    circle,
    hsl(var(--primary) / 0.28) 0%,
    transparent 68%
  );
  filter: blur(30px);
  pointer-events: none;
}
/* ä»ªè¡¨ */
.auth-scene__gauge {
  position: absolute;
  z-index: -1;
  top: 45%;
  right: -18%;
  width: min(40rem, 56vw);
  height: auto;
  translate: 0 -50%;
  pointer-events: none;
}
.gauge__track {
  fill: none;
  stroke: rgb(255 255 255 / 0.16);
  stroke-width: 1;
}
.gauge__track--dashed {
  stroke: rgb(255 255 255 / 0.08);
  stroke-dasharray: 1 7;
  stroke-linecap: round;
}
.gauge__value {
  fill: none;
  stroke: hsl(var(--primary) / 0.9);
  stroke-width: 2;
  stroke-linecap: round;
  filter: drop-shadow(0 0 7px hsl(var(--primary) / 0.6));
}
.gauge__tick {
  stroke: rgb(255 255 255 / 0.2);
  stroke-width: 1;
}
.gauge__tick--major {
  stroke: rgb(255 255 255 / 0.48);
  stroke-width: 1.5;
}
.gauge__needle {
  stroke: hsl(var(--primary));
  stroke-width: 2.5;
  stroke-linecap: round;
  transform: rotate(-42deg);
  transform-origin: 200px 200px;
  animation: gauge-needle 11s cubic-bezier(0.45, 0, 0.55, 1) infinite alternate;
}
@keyframes gauge-needle {
  from {
    transform: rotate(-45deg);
  }
  to {
    transform: rotate(-39deg);
  }
}
.gauge__hub {
  fill: #0a0e14;
  stroke: hsl(var(--primary) / 0.9);
  stroke-width: 1.5;
}
.gauge__hub-dot {
  fill: hsl(var(--primary));
}
/* å“ç‰Œ */
.auth-scene__brand {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.875rem;
}
/* æ·±è‰²å°é¢ä¸Šçš„æµ…色铭牌托底:让深蓝标识在石墨底上依然清晰 */
.auth-scene__logo {
  width: 2.75rem;
  height: 2.75rem;
  padding: 0.25rem;
  object-fit: contain;
  border-radius: 0.625rem;
  /* å†·è°ƒé‡‘属铭牌,与整体蓝色调统一,避免纯白在石墨底上过于刺眼 */
  background: rgb(233 240 249 / 0.96);
  box-shadow:
    inset 0 0 0 1px rgb(255 255 255 / 0.75),
    0 4px 14px -6px rgb(0 0 0 / 0.55);
}
.auth-scene__name {
  margin: 0;
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: #f2f6fb;
}
/* ä¸»æ–‡æ¡ˆ */
.auth-scene__intro {
  position: relative;
  z-index: 1;
  max-width: 33rem;
  margin-top: auto;
  padding-top: 3.5rem;
}
/* æ–‡å­—区底部压暗,保证仪表盘图样穿过时文案仍清晰 */
.auth-scene__intro::before {
  position: absolute;
  z-index: -1;
  inset: -1.5rem -6rem -2.5rem -3rem;
  content: '';
  background: radial-gradient(
    120% 100% at 0% 100%,
    rgb(5 7 10 / 0.82) 0%,
    rgb(5 7 10 / 0.45) 45%,
    transparent 78%
  );
  pointer-events: none;
}
.auth-scene__eyebrow {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin: 0 0 1.25rem;
  font-family: ui-monospace, 'SFMono-Regular', 'Cascadia Mono', monospace;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: hsl(var(--primary) / 0.95);
}
.auth-scene__eyebrow-rule {
  width: 2.5rem;
  height: 1px;
  background: linear-gradient(90deg, hsl(var(--primary)), transparent);
}
.auth-scene__title {
  margin: 0;
  font-size: clamp(1.875rem, 2.5vw, 2.625rem);
  font-weight: 700;
  line-height: 1.26;
  letter-spacing: -0.01em;
  color: #f7fafd;
}
.auth-scene__desc {
  max-width: 29rem;
  margin: 1.125rem 0 0;
  font-size: 0.9375rem;
  line-height: 1.75;
  color: #93a1b5;
}
/* ==================== å³ï¼šç™»å½•面板 ==================== */
.auth-scene__panel {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  overflow-y: auto;
  padding: clamp(3.5rem, 5vw, 5rem) clamp(1.5rem, 4.5vw, 4rem);
  /* æœºåŠ å·¥å°é¢ï¼šæ¯”å¡ç‰‡ä½Žä¸€å±‚çš„æµ…ç°ï¼Œä¿è¯ç™½å¡åœ¨æµ…è‰²ä¸»é¢˜ä¸‹æœ‰å±‚æ¬¡ */
  background-color: hsl(var(--background-deep));
  background-image:
    linear-gradient(to right, hsl(var(--foreground) / 0.035) 1px, transparent 1px),
    linear-gradient(to bottom, hsl(var(--foreground) / 0.035) 1px, transparent 1px);
  background-size: 24px 24px;
}
.auth-scene__toolbar {
  position: absolute;
  top: 1.25rem;
  right: 1.25rem;
  display: flex;
  align-items: center;
  gap: 0.125rem;
}
.auth-scene__form {
  position: relative;
  width: 100%;
  max-width: 24rem;
  margin-inline: auto;
  padding: clamp(1.75rem, 2.5vw, 2.25rem);
  border: 1px solid hsl(var(--border));
  border-radius: 1.25rem;
  background: hsl(var(--card));
  box-shadow:
    inset 0 1px 0 hsl(0 0% 100% / 0.7),
    0 1px 2px hsl(var(--foreground) / 0.06),
    0 24px 56px -28px hsl(var(--foreground) / 0.35);
}
/* å¡ç‰‡å››è§’定位标记 */
.auth-scene__corner {
  position: absolute;
  width: 0.875rem;
  height: 0.875rem;
  border-color: hsl(var(--primary) / 0.55);
  border-style: solid;
  border-width: 0;
  pointer-events: none;
}
.auth-scene__corner--tl {
  top: -1px;
  left: -1px;
  border-top-width: 2px;
  border-left-width: 2px;
  border-top-left-radius: 1.25rem;
}
.auth-scene__corner--tr {
  top: -1px;
  right: -1px;
  border-top-width: 2px;
  border-right-width: 2px;
  border-top-right-radius: 1.25rem;
}
.auth-scene__corner--bl {
  bottom: -1px;
  left: -1px;
  border-bottom-width: 2px;
  border-left-width: 2px;
  border-bottom-left-radius: 1.25rem;
}
.auth-scene__corner--br {
  right: -1px;
  bottom: -1px;
  border-right-width: 2px;
  border-bottom-width: 2px;
  border-bottom-right-radius: 1.25rem;
}
.auth-scene__copyright {
  position: absolute;
  bottom: 1rem;
  left: 0;
  width: 100%;
  text-align: center;
  font-size: 0.75rem;
  color: hsl(var(--muted-foreground));
}
/* ==================== å“åº”式 ==================== */
@media (max-width: 1023px) {
  .auth-scene {
    grid-template-columns: minmax(0, 1fr);
  }
  .auth-scene__face {
    min-height: 14rem;
    padding: 1.5rem 1.5rem 1.75rem;
  }
  .auth-scene__gauge {
    top: 58%;
    right: -34%;
    width: 26rem;
    opacity: 0.6;
  }
  .auth-scene__intro {
    padding-top: 2.25rem;
  }
  .auth-scene__title {
    font-size: 1.5rem;
    line-height: 1.32;
  }
  .auth-scene__desc {
    margin-top: 0.625rem;
    font-size: 0.875rem;
    line-height: 1.6;
  }
  .auth-scene__panel {
    justify-content: flex-start;
    /* é¡¶éƒ¨ç•™å‡ºå·¥å…·æ é«˜åº¦ï¼Œé¿å…å³ä¸Šè§’按钮压住登录卡片 */
    padding: 4.25rem 1.25rem 4.5rem;
  }
  .auth-scene__form {
    padding: 1.5rem;
  }
  .auth-scene__copyright {
    position: static;
    margin-top: 2.5rem;
  }
}
@media (prefers-reduced-motion: reduce) {
  .gauge__needle {
    animation: none;
  }
}
</style>
src/layouts/auth.vue
@@ -1,11 +1,12 @@
<script lang="ts" setup>
import { computed, onMounted } from 'vue';
import { AuthPageLayout } from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
import { useCompanyConfigStore } from '#/store/company-config';
import AuthScene from './auth-scene.vue';
declare const __APP_LOGO__: string;
@@ -16,14 +17,17 @@
  companyConfigStore.loadFromServer();
});
const appName = computed(() => preferences.copyright.companyName);
// ç™»å½•页 logo ä¼˜å…ˆå–数据库配置(preferences.logo.source),未配置时回退编译期默认 __APP_LOGO__
// å…¬å¸åä¼˜å…ˆå–数据库配置,未配置时回退编译期默认(preferences.copyright.companyName)
const appName = computed(
  () => companyConfigStore.companyName || preferences.copyright.companyName,
);
// ç™»å½•页 logo ä¼˜å…ˆå–数据库配置(companyConfig / preferences.logo.source),未配置时回退编译期默认 __APP_LOGO__
const logo = computed(() => {
  const source = preferences.logo.source || __APP_LOGO__;
  const source =
    companyConfigStore.logoUrl || preferences.logo.source || __APP_LOGO__;
  // æœªé…ç½®å…¬å¸æ ‡å¿—时不展示框架演示 Logo。
  return source?.includes('@vbenjs/static-source') ? '' : source;
});
const logoDark = computed(() => preferences.logo.sourceDark);
// æ ‡é¢˜/描述优先取公司信息配置,未配置时回退标品文案
const pageTitle = computed(
  () => companyConfigStore.loginTitle || $t('authentication.pageTitle'),
@@ -34,14 +38,14 @@
</script>
<template>
  <AuthPageLayout
  <AuthScene
    :app-name="appName"
    :logo="logo"
    :logo-dark="logoDark"
    :page-description="pageDescription"
    :page-title="pageTitle"
  >
    <!-- è‡ªå®šä¹‰å·¥å…·æ  -->
    <!-- <template #toolbar></template> -->
  </AuthPageLayout>
    <template #copyright>
      <span v-if="appName">{{ appName }}</span>
    </template>
  </AuthScene>
</template>
src/locales/langs/en-US/authentication.json
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
{
  "pageTitle": "Every operation, traceable",
  "pageDesc": "One manufacturing operations backbone for production, quality, equipment and inventory.",
  "loginEyebrow": "Operator authentication",
  "loginSubmit": "Enter console",
  "loginSubmitting": "Verifying"
}
src/locales/langs/zh-CN/authentication.json
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
{
  "pageTitle": "让每一道工序都有据可查",
  "pageDesc": "生产、质量、设备、仓储一体的制造运营底座,把车间现场变成可读的数据。",
  "loginEyebrow": "操作员身份验证",
  "loginSubmit": "进入控制台",
  "loginSubmitting": "正在校验"
}
src/packages/effects/layouts/src/basic/index.ts
@@ -1 +1,2 @@
export { default as BasicLayout } from './layout.vue';
export { Copyright } from './copyright';
src/views/_core/authentication/login.vue
@@ -1,104 +1,41 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { AuthApi } from '#/api/core/auth';
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { AuthenticationLogin, Verification, z } from '@vben/common-ui';
import { isCaptchaEnable, isTenantEnable } from '@vben/hooks';
import { useVbenForm, Verification, VbenCheckbox, z } from '@vben/common-ui';
import { isCaptchaEnable } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import {
  checkCaptcha,
  getCaptcha,
  getTenantByWebsite,
  getTenantSimpleList,
} from '#/api/core/auth';
import { checkCaptcha, getCaptcha } from '#/api/core/auth';
import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' });
const authStore = useAuthStore();
const accessStore = useAccessStore();
const tenantEnable = isTenantEnable();
const captchaEnable = isCaptchaEnable();
const loginRef = ref();
const verifyRef = ref();
const captchaType = 'blockPuzzle'; // éªŒè¯ç ç±»åž‹ï¼š'blockPuzzle' | 'clickWord'
/** èŽ·å–ç§Ÿæˆ·åˆ—è¡¨ */
const tenantList = ref<AuthApi.TenantResult[]>([]); // ç§Ÿæˆ·åˆ—表
async function fetchTenantList() {
  if (!tenantEnable) {
    return;
  }
  try {
    // èŽ·å–ç§Ÿæˆ·åˆ—è¡¨
    tenantList.value = await getTenantSimpleList();
  } catch (error) {
    console.error('获取租户列表失败:', error);
  }
}
/** è®°ä½è´¦å·ï¼šæ²¿ç”¨åŽŸæœ‰ key,保证老用户已记住的账号不丢失 */
const REMEMBER_ME_KEY = `REMEMBER_ME_USERNAME_${location.hostname}`;
const rememberedUsername = localStorage.getItem(REMEMBER_ME_KEY) || '';
const rememberMe = ref(Boolean(rememberedUsername));
/** å¤„理登录 */
async function handleLogin(values: any) {
  // å¦‚果开启验证码,则先验证验证码
  if (captchaEnable) {
    verifyRef.value.show();
    return;
  }
  // æ— éªŒè¯ç ï¼Œç›´æŽ¥ç™»å½•
  await authStore.authLogin('username', values);
}
/** éªŒè¯ç é€šè¿‡ï¼Œæ‰§è¡Œç™»å½• */
async function handleVerifySuccess({ captchaVerification }: any) {
  try {
    await authStore.authLogin('username', {
      ...(await loginRef.value.getFormApi().getValues()),
      captchaVerification,
    });
  } catch (error) {
    console.error('Error in handleLogin:', error);
  }
}
/** ç»„件挂载时获取租户信息 */
onMounted(() => {
  fetchTenantList();
});
/** ç­‰å¾…验证码通过后再提交的表单值 */
const pendingValues = ref<Record<string, any>>({});
const formSchema = computed((): VbenFormSchema[] => {
  return [
    // {
    //   component: 'VbenSelect',
    //   componentProps: {
    //     options: tenantList.value.map((item) => ({
    //       label: item.name,
    //       value: item.id.toString(),
    //     })),
    //     placeholder: $t('authentication.tenantTip'),
    //   },
    //   fieldName: 'tenantId',
    //   label: $t('authentication.tenant'),
    //   dependencies: {
    //     triggerFields: ['tenantId'],
    //     if: tenantEnable,
    //     trigger(values) {
    //       accessStore.setTenantId(values.tenantId ? Number(values.tenantId) : null);
    //     },
    //   },
    // },
    {
      component: 'VbenInput',
      componentProps: {
        placeholder: $t('authentication.usernameTip'),
        autocomplete: 'username',
        'aria-label': $t('authentication.username'),
        autocomplete: 'username',
        class: 'login-field',
        placeholder: $t('authentication.usernameTip'),
      },
      fieldName: 'username',
      label: $t('authentication.username'),
@@ -110,9 +47,10 @@
    {
      component: 'VbenInputPassword',
      componentProps: {
        placeholder: $t('authentication.passwordTip'),
        autocomplete: 'current-password',
        'aria-label': $t('authentication.password'),
        autocomplete: 'current-password',
        class: 'login-field',
        placeholder: $t('authentication.passwordTip'),
      },
      fieldName: 'password',
      label: $t('authentication.password'),
@@ -123,22 +61,100 @@
    },
  ];
});
const [Form, formApi] = useVbenForm(
  reactive({
    commonConfig: {
      hideLabel: false,
      hideRequiredMark: true,
    },
    layout: 'vertical' as const,
    schema: formSchema,
    showDefaultActions: false,
  }),
);
/** è°ƒç”¨ç™»å½•接口 */
async function submitLogin(values: Record<string, any>) {
  try {
    await authStore.authLogin('username', values);
  } catch (error) {
    // é”™è¯¯æç¤ºç”±è¯·æ±‚拦截器统一弹出,这里仅避免未捕获的 Promise æ‹’绝
    console.error('登录失败:', error);
  }
}
/** æäº¤ï¼šæ ¡éªŒ -> è®°ä½è´¦å· -> éªŒè¯ç  -> ç™»å½• */
async function handleSubmit() {
  if (authStore.loginLoading) {
    return;
  }
  const { valid } = await formApi.validate();
  if (!valid) {
    return;
  }
  const values = await formApi.getValues();
  localStorage.setItem(
    REMEMBER_ME_KEY,
    rememberMe.value ? String(values.username ?? '') : '',
  );
  if (captchaEnable) {
    pendingValues.value = values;
    verifyRef.value.show();
    return;
  }
  await submitLogin(values);
}
/** éªŒè¯ç é€šè¿‡ï¼Œæ‰§è¡Œç™»å½• */
async function handleVerifySuccess({ captchaVerification }: any) {
  await submitLogin({ ...pendingValues.value, captchaVerification });
}
onMounted(() => {
  if (rememberedUsername) {
    formApi.setFieldValue('username', rememberedUsername);
  }
});
</script>
<template>
  <div>
    <AuthenticationLogin
      ref="loginRef"
      :form-schema="formSchema"
      :loading="authStore.loginLoading"
      :show-code-login="false"
      :show-qrcode-login="false"
      :show-third-party-login="false"
      :show-register="false"
      :show-forget-password="false"
      :show-doc-link="false"
      @submit="handleLogin"
    />
  <div class="login">
    <header class="login__head">
      <h2 class="login__title">{{ $t('authentication.welcomeBack') }}</h2>
      <p class="login__desc">{{ $t('authentication.loginSubtitle') }}</p>
    </header>
    <Form @keydown.enter.prevent="handleSubmit" />
    <div class="login__row">
      <VbenCheckbox v-model="rememberMe" name="rememberMe">
        {{ $t('authentication.rememberMe') }}
      </VbenCheckbox>
    </div>
    <button
      :disabled="authStore.loginLoading"
      class="login__submit"
      type="button"
      @click="handleSubmit"
    >
      <span class="login__submit-text">
        {{
          authStore.loginLoading
            ? $t('authentication.loginSubmitting')
            : $t('authentication.loginSubmit')
        }}
      </span>
      <span class="login__submit-icon" aria-hidden="true">
        <IconifyIcon
          :class="{ 'animate-pulse': authStore.loginLoading }"
          icon="lucide:arrow-right"
        />
      </span>
    </button>
    <Verification
      ref="verifyRef"
      v-if="captchaEnable"
@@ -151,3 +167,145 @@
    />
  </div>
</template>
<style scoped>
.login {
  --login-control-height: 2.75rem;
}
/* ---------- æ ‡é¢˜ ---------- */
.login__head {
  margin-bottom: 1.5rem;
}
.login__title {
  margin: 0;
  font-size: 1.5rem;
  font-weight: 700;
  line-height: 1.3;
  letter-spacing: -0.01em;
  color: hsl(var(--foreground));
}
.login__desc {
  margin: 0.5rem 0 0;
  font-size: 0.875rem;
  line-height: 1.6;
  color: hsl(var(--muted-foreground));
}
/* ---------- å­—段 ---------- */
.login :deep(label) {
  margin-bottom: 0.375rem;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  color: hsl(var(--muted-foreground));
}
.login :deep(.login-field) {
  height: var(--login-control-height);
  border-radius: 0.625rem;
  border-color: hsl(var(--border));
  background: hsl(var(--muted) / 0.55);
  font-size: 0.9375rem;
  transition:
    background 0.24s cubic-bezier(0.32, 0.72, 0, 1),
    box-shadow 0.24s cubic-bezier(0.32, 0.72, 0, 1);
}
.login :deep(.login-field:focus-visible) {
  background: hsl(var(--background));
  box-shadow: inset 0 0 0 1px hsl(var(--primary));
}
/* placeholder åŠ æ·±ï¼Œä¿è¯åœ¨æµ…ç°åº•ä¸Šä»å¯è¾¨è¯†ï¼ˆAA) */
.login :deep(.login-field::placeholder) {
  color: hsl(var(--muted-foreground));
  opacity: 1;
}
/* å¯†ç å¯è§æ€§æŒ‰é’®ä¸Žè¾“入框对齐 */
.login :deep(.login-field ~ button) {
  padding-right: 0.75rem;
}
/* ---------- è®°ä½è´¦å· ---------- */
.login__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin: 0.25rem 0 1.25rem;
  font-size: 0.875rem;
}
/* ---------- æäº¤æŒ‰é’® ---------- */
.login__submit {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.625rem;
  width: 100%;
  height: var(--login-control-height);
  border: none;
  border-radius: 0.625rem;
  background: hsl(var(--primary));
  color: hsl(var(--primary-foreground));
  font-size: 0.9375rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 0.18),
    0 10px 24px -16px hsl(var(--primary) / 0.95);
  transition:
    transform 0.18s cubic-bezier(0.32, 0.72, 0, 1),
    background 0.18s ease;
}
.login__submit:hover:not(:disabled) {
  background: hsl(var(--primary) / 0.92);
}
.login__submit:focus-visible {
  outline: 2px solid hsl(var(--ring));
  outline-offset: 2px;
}
.login__submit:active:not(:disabled) {
  transform: scale(0.985);
}
.login__submit:disabled {
  cursor: not-allowed;
  opacity: 0.72;
}
.login__submit-text {
  line-height: 1;
}
.login__submit-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 1.5rem;
  border-radius: 9999px;
  background: rgb(255 255 255 / 0.16);
  font-size: 0.875rem;
  transition: transform 0.18s cubic-bezier(0.32, 0.72, 0, 1);
}
.login__submit:hover:not(:disabled) .login__submit-icon {
  transform: translateX(2px);
}
@media (prefers-reduced-motion: reduce) {
  .login :deep(.login-field),
  .login__submit,
  .login__submit-icon {
    transition: none;
  }
}
</style>
src/views/mes/dv/collect/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,263 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesCollectApi } from '#/api/mes/dv/collect';
/** è®¾å¤‡åœ¨çº¿çŠ¶æ€å±•ç¤ºé…ç½® */
export function getOnlineConfig(online?: boolean) {
  return online
    ? { color: 'green', label: '在线', value: true }
    : { color: 'default', label: '离线', value: false };
}
/** è¿è¡ŒçŠ¶æ€å±•ç¤ºé…ç½® */
export function getStsConfig(sts?: number) {
  if (sts === 2) return { color: 'green', label: '生产' };
  if (sts === 1) return { color: 'orange', label: '待机' };
  return { color: 'default', label: '-' };
}
/** è§£æž dataJson é‡Œçš„æ•°å€¼åž‹å­—段 */
export function parseDataJson(dataJson?: string): Record<string, number | string> {
  if (!dataJson) return {};
  try {
    const obj = JSON.parse(dataJson);
    const result: Record<string, number | string> = {};
    Object.keys(obj).forEach((k) => {
      const v = obj[k];
      if (typeof v === 'number') {
        // æµ®ç‚¹æ•°ä¿ç•™ 2 ä½ï¼Œé¿å… 1.9984014E-15 è¿™ç±»ç§‘学计数法
        result[k] = Number.isInteger(v) ? v : Number(v.toFixed(2));
      } else {
        result[k] = v;
      }
    });
    return result;
  } catch {
    return {};
  }
}
/** ä»Ž dataJson æå–指定 key çš„æ•°å€¼ */
export function getJsonNumber(
  dataJson: string | undefined,
  key: string,
): number | undefined {
  const parsed = parseDataJson(dataJson);
  const v = parsed[key];
  return typeof v === 'number' ? v : undefined;
}
/** è®¾å¤‡çŠ¶æ€åˆ—è¡¨çš„æŸ¥è¯¢è¡¨å• */
export function useDeviceSearchSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'keyword',
      label: '设备名称',
      component: 'Input',
      componentProps: {
        placeholder: '输入设备名称或编码搜索',
        allowClear: true,
      },
    },
    {
      fieldName: 'online',
      label: '在线状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        placeholder: '全部',
        options: [
          { label: '在线', value: true },
          { label: '离线', value: false },
        ],
      },
    },
  ];
}
/** å®žæ—¶åŽ†å²åˆ†é¡µçš„æŸ¥è¯¢è¡¨å• */
export function useHistorySearchSchema(
  deviceOptions: { label: string; value: string }[],
): VbenFormSchema[] {
  return [
    {
      fieldName: 'deviceCode',
      label: '数采设备',
      component: 'Select',
      componentProps: {
        allowClear: true,
        placeholder: '全部设备',
        options: deviceOptions,
        showSearch: true,
        optionFilterProp: 'label',
      },
    },
    {
      fieldName: 'collectTime',
      label: '采集时间',
      component: 'RangePicker',
      componentProps: {
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
    },
  ];
}
/** æŠ¥è­¦åˆ†é¡µçš„æŸ¥è¯¢è¡¨å• */
export function useAlarmSearchSchema(
  deviceOptions: { label: string; value: string }[],
): VbenFormSchema[] {
  return [
    {
      fieldName: 'deviceCode',
      label: '数采设备',
      component: 'Select',
      componentProps: {
        allowClear: true,
        placeholder: '全部设备',
        options: deviceOptions,
        showSearch: true,
        optionFilterProp: 'label',
      },
    },
    {
      fieldName: 'status',
      label: '报警状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        placeholder: '全部',
        options: [
          { label: '未消除', value: 0 },
          { label: '已消除', value: 1 },
        ],
      },
    },
    {
      fieldName: 'wmTime',
      label: '报警时间',
      component: 'RangePicker',
      componentProps: {
        showTime: true,
        format: 'YYYY-MM-DD HH:mm:ss',
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
    },
  ];
}
/** è®¾å¤‡çŠ¶æ€è¡¨æ ¼åˆ— */
export function useDeviceGridColumns(): VxeTableGridOptions<MesCollectApi.CollectDevice>['columns'] {
  return [
    {
      field: 'machineryName',
      title: '设备名称',
      minWidth: 140,
      slots: { default: 'machineryName' },
    },
    { field: 'machineryCode', title: '设备编码', width: 140 },
    { field: 'collectDeviceCode', title: '数采编码', width: 130 },
    {
      field: 'online',
      title: '在线状态',
      width: 100,
      slots: { default: 'online' },
    },
    {
      field: 'sts',
      title: '运行状态',
      width: 100,
      slots: { default: 'sts' },
    },
    {
      field: 'lastCollectTime',
      title: '最近采集',
      width: 170,
    },
    {
      field: 'currentData',
      title: '当前数据',
      minWidth: 260,
      slots: { default: 'currentData' },
    },
    {
      field: 'activeAlarmCount',
      title: '未消除报警',
      width: 110,
      slots: { default: 'alarm' },
    },
    {
      field: 'taskName',
      title: '当前任务',
      width: 160,
      formatter: ({ cellValue }: any) => cellValue || '-',
    },
    {
      field: 'workstationName',
      title: '工作站',
      width: 140,
      formatter: ({ cellValue }: any) => cellValue || '-',
    },
    {
      field: 'actions',
      title: '操作',
      width: 130,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
/** å®žæ—¶åŽ†å²è¡¨æ ¼åˆ— */
export function useHistoryGridColumns(): VxeTableGridOptions<MesCollectApi.CollectRealtime>['columns'] {
  return [
    { field: 'id', title: '编号', width: 90 },
    { field: 'deviceCode', title: '数采设备', width: 140 },
    { field: 'collectTime', title: '采集时间', width: 170 },
    {
      field: 'stsName',
      title: '运行状态',
      width: 100,
      formatter: ({ cellValue }: any) => cellValue || '-',
    },
    {
      field: 'dataJson',
      title: '采集数据',
      minWidth: 320,
      slots: { default: 'dataJson' },
    },
    { field: 'taskId', title: '任务编号', width: 100 },
    { field: 'workOrderId', title: '工单编号', width: 100 },
  ];
}
/** æŠ¥è­¦è¡¨æ ¼åˆ— */
export function useAlarmGridColumns(): VxeTableGridOptions<MesCollectApi.CollectAlarm>['columns'] {
  return [
    { field: 'id', title: '编号', width: 90 },
    { field: 'deviceCode', title: '数采设备', width: 140 },
    {
      field: 'wmMsg',
      title: '报警内容',
      minWidth: 200,
      formatter: ({ cellValue }: any) => cellValue || '-',
    },
    { field: 'wmTime', title: '报警时间', width: 170 },
    {
      field: 'statusName',
      title: '状态',
      width: 100,
      slots: { default: 'status' },
    },
    { field: 'clearTime', title: '消除时间', width: 170 },
    {
      field: 'actions',
      title: '操作',
      width: 120,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/mes/dv/collect/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,483 @@
<script lang="ts" setup>
import type { MesCollectApi } from '#/api/mes/dv/collect';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { CountTo, Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Badge,
  Button,
  Card,
  Col,
  Empty,
  Input,
  message,
  Row,
  Select,
  Space,
  Table,
  Tag,
  Tooltip,
} from 'ant-design-vue';
import { getCollectDeviceList } from '#/api/mes/dv/collect';
import DetailModal from './modules/detail.vue';
defineOptions({ name: 'MesDvCollect' });
/** è‡ªåŠ¨åˆ·æ–°é—´éš”ï¼ˆæ¯«ç§’ï¼‰ */
const REFRESH_INTERVAL = 10_000;
const allRows = ref<MesCollectApi.CollectDevice[]>([]);
const rows = ref<MesCollectApi.CollectDevice[]>([]);
const loading = ref(false);
const errorMsg = ref('');
const lastRefresh = ref('');
const keyword = ref('');
const onlineFilter = ref<string | undefined>(undefined);
const autoRefresh = ref(true);
let timer: ReturnType<typeof setInterval> | undefined;
const [DetailModalComp, detailModalApi] = useVbenModal({
  connectedComponent: DetailModal,
  destroyOnClose: true,
});
/** KPI ç»Ÿè®¡ */
const kpi = computed(() => {
  const total = allRows.value.length;
  const online = allRows.value.filter((r) => r.online).length;
  const alarm = allRows.value.reduce(
    (s, r) => s + (r.activeAlarmCount ?? 0),
    0,
  );
  const running = allRows.value.filter((r) => r.sts === 2).length;
  return { total, online, offline: total - online, alarm, running };
});
/** KPI å¡ç‰‡é…ç½® */
const kpiCards = computed(() => [
  {
    label: '设备总数',
    value: kpi.value.total,
    unit: '台',
    icon: 'lucide:monitor-cog',
    color: '#409eff',
    gradient: 'linear-gradient(135deg, #409eff, #66b1ff)',
    sub: `运行 ${kpi.value.running} å°`,
  },
  {
    label: '在线设备',
    value: kpi.value.online,
    unit: '台',
    icon: 'lucide:wifi',
    color: '#67c23a',
    gradient: 'linear-gradient(135deg, #67c23a, #85ce61)',
    sub: `离线 ${kpi.value.offline} å°`,
  },
  {
    label: '未消除报警',
    value: kpi.value.alarm,
    unit: '条',
    icon: 'lucide:bell-ring',
    color: kpi.value.alarm > 0 ? '#f56c6c' : '#909399',
    gradient:
      kpi.value.alarm > 0
        ? 'linear-gradient(135deg, #f56c6c, #f89898)'
        : 'linear-gradient(135deg, #909399, #b1b3b8)',
    sub: kpi.value.alarm > 0 ? '需要处理' : '一切正常',
  },
  {
    label: '采集频率',
    value: 10,
    unit: '秒',
    icon: 'lucide:refresh-cw',
    color: '#e6a23c',
    gradient: 'linear-gradient(135deg, #e6a23c, #ebb563)',
    sub: autoRefresh.value ? '自动刷新中' : '已暂停',
  },
]);
/** åŠ è½½è®¾å¤‡åˆ—è¡¨ */
async function load() {
  loading.value = true;
  errorMsg.value = '';
  try {
    const list = await getCollectDeviceList();
    allRows.value = list || [];
    applyFilter();
    lastRefresh.value = new Date().toLocaleTimeString('zh-CN', {
      hour12: false,
    });
  } catch (e: any) {
    errorMsg.value = e?.message || '加载数采设备失败,请重试';
    allRows.value = [];
    rows.value = [];
  } finally {
    loading.value = false;
  }
}
/** å‰ç«¯è¿‡æ»¤ */
function applyFilter() {
  let list = allRows.value;
  const kw = keyword.value.trim();
  if (kw) {
    list = list.filter(
      (r) =>
        r.machineryName?.includes(kw) ||
        r.machineryCode?.includes(kw) ||
        r.collectDeviceCode?.includes(kw),
    );
  }
  if (onlineFilter.value !== undefined) {
    const target = onlineFilter.value === 'online';
    list = list.filter((r) => Boolean(r.online) === target);
  }
  rows.value = list;
}
/** é‡ç½®ç­›é€‰ */
function handleReset() {
  keyword.value = '';
  onlineFilter.value = undefined;
  applyFilter();
}
/** åˆ‡æ¢è‡ªåŠ¨åˆ·æ–° */
function toggleAutoRefresh() {
  autoRefresh.value = !autoRefresh.value;
  setupTimer();
  message.info(
    autoRefresh.value ? '已开启自动刷新(10 ç§’)' : '已关闭自动刷新',
  );
}
function setupTimer() {
  if (timer) {
    clearInterval(timer);
    timer = undefined;
  }
  if (autoRefresh.value) {
    timer = setInterval(() => load(), REFRESH_INTERVAL);
  }
}
/** æ‰“开详情 */
function handleDetail(row: MesCollectApi.CollectDevice) {
  detailModalApi
    .setData({
      deviceCode: row.collectDeviceCode,
      machineryName: row.machineryName,
    })
    .open();
}
/** è§£æž dataJson */
function parseData(row: any): Record<string, number | string> {
  const raw = row.dataJson;
  if (!raw) return {};
  try {
    const obj = JSON.parse(raw);
    const out: Record<string, number | string> = {};
    Object.keys(obj).forEach((k) => {
      const v = obj[k];
      out[k] =
        typeof v === 'number'
          ? Number.isInteger(v)
            ? v
            : Number(v.toFixed(2))
          : v;
    });
    return out;
  } catch {
    return {};
  }
}
/** å½“前数据标签 */
function dataTags(row: any) {
  const data = parseData(row);
  return Object.keys(data)
    .slice(0, 3)
    .map((k) => ({ key: k, value: data[k] }));
}
/** è¡¨æ ¼åˆ— */
const columns = [
  {
    title: '设备',
    dataIndex: 'machineryName',
    width: 170,
    fixed: 'left' as const,
    customRender: ({ record }: any) => record,
  },
  {
    title: '数采编码',
    dataIndex: 'collectDeviceCode',
    width: 130,
  },
  {
    title: '状态',
    dataIndex: 'online',
    width: 110,
    customRender: ({ record }: any) => record,
  },
  {
    title: '当前数据',
    dataIndex: 'currentData',
    minWidth: 300,
    customRender: ({ record }: any) => record,
  },
  {
    title: '最近采集',
    dataIndex: 'lastCollectTime',
    width: 165,
  },
  {
    title: '报警',
    dataIndex: 'activeAlarmCount',
    width: 80,
    align: 'center' as const,
    customRender: ({ record }: any) => record,
  },
  {
    title: '操作',
    dataIndex: 'actions',
    width: 90,
    align: 'center' as const,
    fixed: 'right' as const,
    customRender: ({ record }: any) => record,
  },
];
onMounted(() => {
  load();
  setupTimer();
});
onBeforeUnmount(() => {
  if (timer) clearInterval(timer);
});
</script>
<style scoped>
:deep(.ant-table-thead > tr > th) {
  background: #fafafa !important;
  color: #606266;
  font-weight: 600;
}
/* è¡¨å¤´å·¦å†…边距,避免贴边 */
:deep(.ant-table-thead > tr > th:first-child) {
  padding-left: 16px !important;
}
/* å•元格左内边距 */
:deep(.ant-table-tbody > tr > td) {
  padding-top: 12px;
  padding-bottom: 12px;
  padding-left: 16px;
  padding-right: 16px;
}
/* äº¤å‰è¡ŒèƒŒæ™¯ */
:deep(.ant-table-tbody > tr:hover > td) {
  background: #f5f7fa;
}
</style>
<template>
  <Page auto-content-height>
    <DetailModalComp />
    <!-- KPI å¡ç‰‡åŒº -->
    <Row :gutter="16" class="mb-4">
      <Col
        v-for="card in kpiCards"
        :key="card.label"
        :lg="6"
        :md="12"
        :sm="12"
        :xl="6"
        :xs="24"
        class="mb-4"
      >
        <Card class="h-full transition-all hover:shadow-lg">
          <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.gradient }"
            >
              <IconifyIcon class="size-7" :icon="card.icon" />
            </div>
            <div class="min-w-0 flex-1">
              <div class="text-muted-foreground mb-1 text-sm">
                {{ card.label }}
              </div>
              <div class="flex items-baseline gap-1">
                <CountTo
                  class="text-2xl font-bold leading-tight"
                  :end-val="card.value"
                  :duration="1200"
                  :style="{ color: card.color }"
                />
                <span class="text-muted-foreground text-xs">{{ card.unit }}</span>
              </div>
              <div class="text-muted-foreground mt-1 text-xs">
                {{ card.sub }}
              </div>
            </div>
          </div>
        </Card>
      </Col>
    </Row>
    <!-- å·¥å…·æ ï¼šå•行紧凑布局 -->
    <div class="mb-3 flex items-center gap-2">
      <Input
        v-model:value="keyword"
        style="width: 200px"
        placeholder="设备名称 / ç¼–码"
        allow-clear
        @press-enter="applyFilter"
      >
        <template #prefix>
          <IconifyIcon class="text-gray-400" icon="lucide:search" />
        </template>
      </Input>
      <Select
        v-model:value="onlineFilter"
        style="width: 110px"
        allow-clear
        placeholder="全部状态"
        :options="[
          { label: '在线', value: 'online' },
          { label: '离线', value: 'offline' },
        ]"
      />
      <Button type="primary" :loading="loading" @click="load">查询</Button>
      <Button @click="handleReset">重置</Button>
      <Button
        :type="autoRefresh ? 'default' : 'dashed'"
        @click="toggleAutoRefresh"
      >
        <template #icon>
          <IconifyIcon :icon="autoRefresh ? 'lucide:pause' : 'lucide:play'" />
        </template>
        {{ autoRefresh ? '暂停' : '自动刷新' }}
      </Button>
      <span v-if="lastRefresh" class="text-muted-foreground ml-auto text-xs">
        æ›´æ–°äºŽ {{ lastRefresh }}
      </span>
    </div>
    <!-- é”™è¯¯æç¤º -->
    <div
      v-if="errorMsg"
      class="mb-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600"
    >
      <IconifyIcon icon="lucide:alert-circle" />
      {{ errorMsg }}
    </div>
    <!-- æ•°æ®è¡¨æ ¼ï¼šflex-1 + min-h-0 è®©è¡¨æ ¼åŒºè‡ªé€‚应剩余高度并可滚动 -->
    <div class="flex min-h-0 flex-1 flex-col">
      <Table
        :columns="columns"
        :data-source="rows"
        :loading="loading"
        :pagination="{
          pageSize: 20,
          showSizeChanger: true,
          showTotal: (t: number) => `共 ${t} å°è®¾å¤‡`,
        }"
        :row-key="(r: any) => r.machineryId"
        size="middle"
        :scroll="{ x: 1100 }"
        class="flex-1"
        :header-cell-style="{
          background: '#fafafa',
          color: '#606266',
          fontWeight: 600,
          padding: '12px 16px',
          whiteSpace: 'nowrap',
        }"
        :cell-style="{ padding: '12px 16px' }"
      >
        <template #bodyCell="{ column, record }">
          <!-- è®¾å¤‡åç§° -->
          <template v-if="column.dataIndex === 'machineryName'">
            <a class="flex flex-col" @click="handleDetail(record)">
              <span class="font-medium text-blue-600 hover:text-blue-700">
                {{ record.machineryName }}
              </span>
              <span class="text-muted-foreground text-xs">
                {{ record.machineryCode }}
              </span>
            </a>
          </template>
          <!-- åœ¨çº¿çŠ¶æ€ -->
          <template v-else-if="column.dataIndex === 'online'">
            <Badge
              :status="record.online ? 'success' : 'default'"
              :text="record.online ? '在线' : '离线'"
            />
          </template>
          <!-- å½“前数据 -->
          <template v-else-if="column.dataIndex === 'currentData'">
            <div v-if="dataTags(record).length > 0" class="flex flex-wrap gap-1">
              <Tag
                v-for="tag in dataTags(record)"
                :key="tag.key"
                class="!m-0 font-mono text-xs"
                color="blue"
              >
                {{ tag.key }}: {{ tag.value }}
              </Tag>
            </div>
            <span v-else class="text-gray-400 text-xs">暂无数据</span>
          </template>
          <!-- æŠ¥è­¦æ•° -->
          <template v-else-if="column.dataIndex === 'activeAlarmCount'">
            <Tooltip
              v-if="(record.activeAlarmCount ?? 0) > 0"
              :title="`${record.activeAlarmCount} æ¡æœªæ¶ˆé™¤æŠ¥è­¦`"
            >
              <Tag color="red" class="!m-0 cursor-pointer">
                <IconifyIcon class="mr-0.5 inline" icon="lucide:bell" />
                {{ record.activeAlarmCount }}
              </Tag>
            </Tooltip>
            <span v-else class="text-gray-300">—</span>
          </template>
          <!-- æ“ä½œ -->
          <template v-else-if="column.dataIndex === 'actions'">
            <Button type="link" size="small" @click="handleDetail(record)">
              è¯¦æƒ…
            </Button>
          </template>
        </template>
        <template #emptyText>
          <Empty
            :image="Empty.PRESENTED_IMAGE_SIMPLE"
            description="暂无数采设备"
          />
        </template>
      </Table>
    </div>
  </Page>
</template>
src/views/mes/dv/collect/modules/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,298 @@
<script lang="ts" setup>
import type { MesCollectApi } from '#/api/mes/dv/collect';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Button,
  Card,
  DatePicker,
  Descriptions,
  Empty,
  message,
  Spin,
  Table,
  Tabs,
  Tag,
} from 'ant-design-vue';
import {
  ackCollectAlarm,
  getCollectAlarmPage,
  getCollectRealtimeHistoryPage,
} from '#/api/mes/dv/collect';
defineOptions({ name: 'MesDvCollectDetail' });
const deviceCode = ref('');
const machineryName = ref('');
const activeTab = ref('history');
const historyLoading = ref(false);
const historyList = ref<MesCollectApi.CollectRealtime[]>([]);
const historyTotal = ref(0);
const historyPage = ref(1);
const historyPageSize = ref(20);
const timeRange = ref<[string, string] | undefined>();
const alarmLoading = ref(false);
const alarmList = ref<MesCollectApi.CollectAlarm[]>([]);
const alarmTotal = ref(0);
const alarmPage = ref(1);
const alarmPageSize = ref(10);
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen) {
    if (!isOpen) return;
    const data = modalApi.getData<{
      deviceCode: string;
      machineryName: string;
    }>();
    deviceCode.value = data?.deviceCode || '';
    machineryName.value = data?.machineryName || '';
    historyPage.value = 1;
    alarmPage.value = 1;
    timeRange.value = undefined;
    activeTab.value = 'history';
    loadHistory();
    loadAlarm();
  },
});
/** è§£æž dataJson */
function parseData(raw?: string): Record<string, number | string> {
  if (!raw) return {};
  try {
    const obj = JSON.parse(raw);
    const out: Record<string, number | string> = {};
    Object.keys(obj).forEach((k) => {
      const v = obj[k];
      out[k] =
        typeof v === 'number'
          ? Number.isInteger(v)
            ? v
            : Number(v.toFixed(2))
          : v;
    });
    return out;
  } catch {
    return {};
  }
}
/** åŠ è½½åŽ†å² */
async function loadHistory() {
  if (!deviceCode.value) return;
  historyLoading.value = true;
  try {
    const params: any = {
      pageNo: historyPage.value,
      pageSize: historyPageSize.value,
      deviceCode: deviceCode.value,
    };
    if (timeRange.value?.length === 2) {
      params.collectTime = timeRange.value;
    }
    const result = await getCollectRealtimeHistoryPage(params);
    historyList.value = result.list || [];
    historyTotal.value = result.total || 0;
  } catch {
    message.error('加载历史数据失败');
  } finally {
    historyLoading.value = false;
  }
}
/** åŠ è½½æŠ¥è­¦ */
async function loadAlarm() {
  if (!deviceCode.value) return;
  alarmLoading.value = true;
  try {
    const result = await getCollectAlarmPage({
      pageNo: alarmPage.value,
      pageSize: alarmPageSize.value,
      deviceCode: deviceCode.value,
    });
    alarmList.value = result.list || [];
    alarmTotal.value = result.total || 0;
  } catch {
    message.error('加载报警记录失败');
  } finally {
    alarmLoading.value = false;
  }
}
/** ç¡®è®¤æ¶ˆé™¤ */
async function handleAck(row: MesCollectApi.CollectAlarm) {
  try {
    await ackCollectAlarm(row.id!);
    message.success('已确认消除');
    loadAlarm();
  } catch {
    message.error('操作失败');
  }
}
function handleTimeChange(val: any) {
  timeRange.value = val;
  historyPage.value = 1;
  loadHistory();
}
/** åŽ†å²åˆ— */
const historyColumns = [
  { title: '采集时间', dataIndex: 'collectTime', width: 170 },
  {
    title: '采集数据',
    dataIndex: 'dataJson',
    ellipsis: true,
  },
];
/** æŠ¥è­¦åˆ— */
const alarmColumns = [
  { title: '报警时间', dataIndex: 'wmTime', width: 170 },
  { title: '报警内容', dataIndex: 'wmMsg', ellipsis: true },
  { title: '状态', dataIndex: 'status', width: 100 },
  { title: '操作', dataIndex: 'actions', width: 110, align: 'center' as const },
];
const historyPagination = computed(() => ({
  current: historyPage.value,
  pageSize: historyPageSize.value,
  total: historyTotal.value,
  showSizeChanger: true,
  size: 'small' as const,
  showTotal: (t: number) => `共 ${t} æ¡`,
}));
const alarmPagination = computed(() => ({
  current: alarmPage.value,
  pageSize: alarmPageSize.value,
  total: alarmTotal.value,
  showSizeChanger: true,
  size: 'small' as const,
  showTotal: (t: number) => `共 ${t} æ¡`,
}));
function handleHistoryChange(p: any) {
  historyPage.value = p.current;
  historyPageSize.value = p.pageSize;
  loadHistory();
}
function handleAlarmChange(p: any) {
  alarmPage.value = p.current;
  alarmPageSize.value = p.pageSize;
  loadAlarm();
}
</script>
<template>
  <Modal :title="`${machineryName} Â· è®¾å¤‡è¯¦æƒ…`" class="w-[1100px]">
    <div class="mb-4 flex items-center gap-3 rounded-lg bg-gray-50 p-3">
      <IconifyIcon class="text-blue-500" icon="lucide:cpu" />
      <span class="text-sm">
        æ•°é‡‡ç¼–码:<b class="font-mono">{{ deviceCode }}</b>
      </span>
      <span class="text-muted-foreground ml-auto text-xs">
        è®¾å¤‡åç§°ï¼š{{ machineryName }}
      </span>
    </div>
    <Tabs v-model:active-key="activeTab" type="card">
      <Tabs.TabPane key="history" tab="采集历史">
        <div class="mb-3 flex items-center gap-3">
          <span class="text-sm">时间范围:</span>
          <DatePicker.RangePicker
            show-time
            format="YYYY-MM-DD HH:mm:ss"
            value-format="YYYY-MM-DD HH:mm:ss"
            @change="handleTimeChange"
          />
          <Button size="small" @click="loadHistory">
            <template #icon>
              <IconifyIcon icon="lucide:refresh-cw" />
            </template>
            åˆ·æ–°
          </Button>
        </div>
        <Spin :spinning="historyLoading">
          <Table
            v-if="historyList.length > 0"
            :columns="historyColumns"
            :data-source="historyList"
            :pagination="historyPagination"
            :row-key="(r: any) => r.id"
            size="small"
            :scroll="{ y: 380 }"
            @change="handleHistoryChange"
          >
            <template #bodyCell="{ column, record }">
              <template v-if="column.dataIndex === 'dataJson'">
                <div class="flex flex-wrap gap-1">
                  <Tag
                    v-for="(v, k) in parseData(record.dataJson)"
                    :key="k"
                    class="!m-0 font-mono text-xs"
                    color="blue"
                  >
                    {{ k }}: {{ v }}
                  </Tag>
                </div>
              </template>
            </template>
          </Table>
          <Empty
            v-else
            :image="Empty.PRESENTED_IMAGE_SIMPLE"
            description="暂无采集历史"
          />
        </Spin>
      </Tabs.TabPane>
      <Tabs.TabPane key="alarm" :tab="`报警记录 (${alarmTotal})`">
        <Spin :spinning="alarmLoading">
          <Table
            v-if="alarmList.length > 0"
            :columns="alarmColumns"
            :data-source="alarmList"
            :pagination="alarmPagination"
            :row-key="(r: any) => r.id"
            size="small"
            :scroll="{ y: 380 }"
            @change="handleAlarmChange"
          >
            <template #bodyCell="{ column, record }">
              <template v-if="column.dataIndex === 'status'">
                <Tag :color="record.status === 0 ? 'red' : 'default'">
                  {{ record.status === 0 ? '未消除' : '已消除' }}
                </Tag>
              </template>
              <template v-else-if="column.dataIndex === 'actions'">
                <Button
                  v-if="record.status === 0"
                  type="link"
                  size="small"
                  @click="handleAck(record)"
                >
                  ç¡®è®¤æ¶ˆé™¤
                </Button>
                <span v-else class="text-gray-400">-</span>
              </template>
            </template>
          </Table>
          <Empty
            v-else
            :image="Empty.PRESENTED_IMAGE_SIMPLE"
            description="暂无报警记录"
          />
        </Spin>
      </Tabs.TabPane>
    </Tabs>
  </Modal>
</template>