feat(chdl): 长辉品牌登录页 + 数采监控页面 + 接口地址切到长辉环境
- 登录页/品牌:新增 CHDL logo、auth-scene 场景组件、登录页文案(中/英)、company-config 接口调整
- 数采监控:新增 mes/dv/collect 页面(KPI 卡片 + 筛选工具栏 + 表格 + 详情弹窗)
- 环境:.env.production / company.config.ts 接口地址由东恒切到长辉
- chore: .gitignore 忽略 dist*.zip 构建产物
| | |
| | | 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 |
| | | |
| | |
| | | |
| | | # Cache |
| | | .cache/ |
| | | .temp/ |
| | | .temp/ |
| | | |
| | | # Build archives |
| | | dist*.zip |
| | |
| | | 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: 'æ ç§', |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | }); |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | /** è·å¾å
¬å¸åçä¿¡æ¯ï¼å
ç»å½å
¬å¼ï¼ç»å½é¡µ / åºç¨å¯å¨æ¶ä½¿ç¨ï¼ */ |
| | | /** |
| | | * è·å¾å
¬å¸åçä¿¡æ¯ï¼å
ç»å½å
¬å¼ï¼ç»å½é¡µ / åºç¨å¯å¨æ¶ä½¿ç¨ï¼ |
| | | * |
| | | * 该æ¥å£åªæ¯å±ç¤ºå¢å¼ºé¡¹ï¼å端æªé¨ç½²è¯¥æ¨¡åãæè¿åæªç»å½æ¶ï¼ |
| | | * é½ä¸åºææç»å½æµç¨ãå æ¤ä½¿ç¨ 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; |
| | | } |
| | | } |
| | | |
| | | /** ä¿åå
¬å¸åçä¿¡æ¯ */ |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | |
| | | <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; |
| | | |
| | |
| | | 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'), |
| | |
| | | </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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | { |
| | | "pageTitle": "Every operation, traceable", |
| | | "pageDesc": "One manufacturing operations backbone for production, quality, equipment and inventory.", |
| | | "loginEyebrow": "Operator authentication", |
| | | "loginSubmit": "Enter console", |
| | | "loginSubmitting": "Verifying" |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | { |
| | | "pageTitle": "让æ¯ä¸éå·¥åºé½ææ®å¯æ¥", |
| | | "pageDesc": "ç产ãè´¨éã设å¤ãä»å¨ä¸ä½çå¶é è¿è¥åºåº§ï¼æè½¦é´ç°åºåæå¯è¯»çæ°æ®ã", |
| | | "loginEyebrow": "æä½å身份éªè¯", |
| | | "loginSubmit": "è¿å
¥æ§å¶å°", |
| | | "loginSubmitting": "æ£å¨æ ¡éª" |
| | | } |
| | |
| | | export { default as BasicLayout } from './layout.vue'; |
| | | export { Copyright } from './copyright'; |
| | |
| | | <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'), |
| | |
| | | { |
| | | 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'), |
| | |
| | | }, |
| | | ]; |
| | | }); |
| | | |
| | | 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" |
| | |
| | | /> |
| | | </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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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' }, |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |