| | |
| | | "#/*": "./src/*" |
| | | }, |
| | | "dependencies": { |
| | | "@ant-design/icons-vue": "^7.0.1", |
| | | "@form-create/ant-design-vue": "catalog:", |
| | | "@form-create/antd-designer": "catalog:", |
| | | "@tinymce/tinymce-vue": "catalog:", |
| | |
| | | "dhtmlx-gantt": "catalog:", |
| | | "diagram-js": "catalog:", |
| | | "fast-xml-parser": "catalog:", |
| | | "gsap": "^3.15.0", |
| | | "highlight.js": "catalog:", |
| | | "livekit-client": "^2.20.0", |
| | | "pinia": "catalog:", |
| | |
| | | |
| | | .: |
| | | dependencies: |
| | | '@ant-design/icons-vue': |
| | | specifier: ^7.0.1 |
| | | version: 7.0.1(vue@3.5.38(typescript@6.0.3)) |
| | | '@form-create/ant-design-vue': |
| | | specifier: 'catalog:' |
| | | version: 3.3.1(vue@3.5.38(typescript@6.0.3)) |
| | |
| | | fast-xml-parser: |
| | | specifier: 'catalog:' |
| | | version: 4.5.6 |
| | | gsap: |
| | | specifier: ^3.15.0 |
| | | version: 3.15.0 |
| | | highlight.js: |
| | | specifier: 'catalog:' |
| | | version: 11.11.1 |
| | |
| | | '@ant-design/colors@6.0.0': |
| | | resolution: {integrity: sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==} |
| | | |
| | | '@ant-design/icons-svg@4.4.2': |
| | | resolution: {integrity: sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==} |
| | | '@ant-design/icons-svg@4.5.0': |
| | | resolution: {integrity: sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==} |
| | | |
| | | '@ant-design/icons-vue@7.0.1': |
| | | resolution: {integrity: sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==} |
| | |
| | | graceful-fs@4.2.11: |
| | | resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} |
| | | |
| | | gsap@3.15.0: |
| | | resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} |
| | | |
| | | gzip-size@7.0.0: |
| | | resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} |
| | | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} |
| | |
| | | dependencies: |
| | | '@ctrl/tinycolor': 3.6.1 |
| | | |
| | | '@ant-design/icons-svg@4.4.2': {} |
| | | '@ant-design/icons-svg@4.5.0': {} |
| | | |
| | | '@ant-design/icons-vue@7.0.1(vue@3.5.38(typescript@6.0.3))': |
| | | dependencies: |
| | | '@ant-design/colors': 6.0.0 |
| | | '@ant-design/icons-svg': 4.4.2 |
| | | '@ant-design/icons-svg': 4.5.0 |
| | | vue: 3.5.38(typescript@6.0.3) |
| | | |
| | | '@antfu/install-pkg@1.1.0': |
| | |
| | | |
| | | graceful-fs@4.2.11: {} |
| | | |
| | | gsap@3.15.0: {} |
| | | |
| | | gzip-size@7.0.0: |
| | | dependencies: |
| | | duplexer: 0.1.2 |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace MesProScanEventApi { |
| | | export interface ScanEvent { |
| | | id?: number; |
| | | eventId?: string; |
| | | bagCode?: string; |
| | | batchCode?: string; |
| | | quantity?: number; |
| | | deviceCode?: string; |
| | | lineCode?: string; |
| | | warehouseId?: number; |
| | | eventTime?: string; |
| | | transactionId?: number; |
| | | status?: 'SUCCESS' | 'FAILED'; |
| | | errorCode?: string; |
| | | errorMessage?: string; |
| | | receivedTime?: string; |
| | | failedTime?: string; |
| | | eventTime?: string; |
| | | remark?: string; |
| | | } |
| | | |
| | | export interface Dashboard { |
| | | latest?: ScanEvent; |
| | | total?: number; |
| | | } |
| | | |
| | | export interface InboundParams { |
| | | eventId: string; |
| | | bagCode: string; |
| | | quantity: number; |
| | | deviceCode: string; |
| | | lineCode?: string; |
| | | locationId: number; |
| | | areaId: number; |
| | | remark?: string; |
| | | } |
| | | } |
| | | |
| | | export function inboundScanEvent(data: MesProScanEventApi.InboundParams) { |
| | | return requestClient.post<MesProScanEventApi.ScanEvent>('/mes/pro/scan-in/inbound', data); |
| | | } |
| | | |
| | | export function getScanEventPage(params: PageParam & { deviceCode?: string; bagCode?: string }) { |
| | | return requestClient.get<PageResult<MesProScanEventApi.ScanEvent>>('/mes/pro/scan-in/page', { params }); |
| | | } |
| | | |
| | | export function getLatestScanEvent(deviceCode?: string) { |
| | | return requestClient.get<MesProScanEventApi.ScanEvent>('/mes/pro/scan-in/latest', { params: { deviceCode } }); |
| | | } |
| | | |
| | | export function getScanDashboard() { |
| | | return requestClient.get<MesProScanEventApi.Dashboard>('/mes/pro/scan-in/dashboard'); |
| | | } |
| | |
| | | }); |
| | | |
| | | const emit = defineEmits<{ |
| | | submit: [Recordable<any>]; |
| | | submit: [Recordable<unknown>]; |
| | | thirdLogin: [type: number]; |
| | | }>(); |
| | | |
| | |
| | | <slot name="title"> |
| | | <Title> |
| | | <slot name="title"> |
| | | {{ title || `${$t('authentication.welcomeBack')} ðð»` }} |
| | | {{ title || $t('authentication.welcomeBack') }} |
| | | </slot> |
| | | <template #desc> |
| | | <span class="text-muted-foreground"> |
| | |
| | | |
| | | <div |
| | | v-if="showRememberMe || showForgetPassword" |
| | | class="mb-6 flex justify-between" |
| | | class="mb-7 flex items-center justify-between" |
| | | > |
| | | <div class="flex-center"> |
| | | <VbenCheckbox |
| | |
| | | }" |
| | | :loading="loading" |
| | | aria-label="login" |
| | | class="w-full" |
| | | class="h-11 w-full rounded-xl text-base font-semibold shadow-sm transition-shadow duration-200 hover:shadow-md focus-visible:ring-2 focus-visible:ring-primary/40" |
| | | @click="handleSubmit" |
| | | > |
| | | {{ submitButtonText || $t('common.login') }} |
| | |
| | | <div class="login-background absolute top-0 left-0 size-full"></div> |
| | | <div |
| | | :key="authPanelLeft ? 'left' : authPanelRight ? 'right' : 'center'" |
| | | class="mr-20 flex-col-center h-full" |
| | | class="mr-20 flex-col-center h-full px-8 text-center" |
| | | :class="{ |
| | | 'enter-x': authPanelLeft, |
| | | '-enter-x': authPanelRight, |
| | |
| | | <div v-if="authPanelCenter" class="relative flex-center w-full"> |
| | | <div class="login-background absolute top-0 left-0 size-full"></div> |
| | | <AuthenticationFormView |
| | | class="w-full rounded-3xl pb-20 shadow-float shadow-primary/5 md:w-2/3 md:bg-background lg:w-1/2 xl:w-[36%]" |
| | | class="w-full rounded-3xl border border-border/60 pb-20 shadow-float shadow-primary/10 md:w-2/3 md:bg-background/95 lg:w-1/2 xl:w-[36%]" |
| | | data-side="bottom" |
| | | > |
| | | <template v-if="copyright" #copyright> |
| | |
| | | |
| | | <style scoped> |
| | | .login-background { |
| | | background: linear-gradient( |
| | | 154deg, |
| | | #07070915 30%, |
| | | hsl(var(--primary) / 30%) 48%, |
| | | #07070915 64% |
| | | ); |
| | | filter: blur(100px); |
| | | background: |
| | | radial-gradient(circle at 72% 38%, hsl(var(--primary) / 24%), transparent 42%), |
| | | linear-gradient(145deg, hsl(var(--background-deep)) 0%, hsl(var(--background)) 100%); |
| | | filter: blur(70px); |
| | | } |
| | | |
| | | .dark { |
| | | .login-background { |
| | | background: linear-gradient( |
| | | 154deg, |
| | | #07070915 30%, |
| | | hsl(var(--primary) / 20%) 48%, |
| | | #07070915 64% |
| | | ); |
| | | filter: blur(100px); |
| | | background: |
| | | radial-gradient(circle at 72% 38%, hsl(var(--primary) / 18%), transparent 42%), |
| | | linear-gradient(145deg, hsl(var(--background-deep)) 0%, hsl(var(--background)) 100%); |
| | | filter: blur(70px); |
| | | } |
| | | } |
| | | </style> |
| | |
| | | |
| | | import type { AuthApi } from '#/api/core/auth'; |
| | | |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { computed, ref } from 'vue'; |
| | | |
| | | import { AuthenticationLogin, Verification, z } from '@vben/common-ui'; |
| | | import { isCaptchaEnable, isTenantEnable } from '@vben/hooks'; |
| | | import { isCaptchaEnable } from '@vben/hooks'; |
| | | 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 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); |
| | | } |
| | | } |
| | | |
| | | /** å¤çç»å½ */ |
| | | async function handleLogin(values: any) { |
| | | // 妿å¼å¯éªè¯ç ï¼åå
éªè¯éªè¯ç |
| | | async function handleLogin(values: AuthApi.LoginParams) { |
| | | 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); |
| | | } |
| | | async function handleVerifySuccess({ |
| | | captchaVerification, |
| | | }: { |
| | | captchaVerification: string; |
| | | }) { |
| | | await authStore.authLogin('username', { |
| | | ...(await loginRef.value.getFormApi().getValues()), |
| | | captchaVerification, |
| | | }); |
| | | } |
| | | |
| | | /** ç»ä»¶æè½½æ¶è·åç§æ·ä¿¡æ¯ */ |
| | | onMounted(() => { |
| | | fetchTenantList(); |
| | | }); |
| | | |
| | | 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: { |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { gsap } from 'gsap'; |
| | | |
| | | import { |
| | | getLatestScanEvent, |
| | | getScanDashboard, |
| | | getScanEventPage, |
| | | inboundScanEvent, |
| | | type MesProScanEventApi, |
| | | } from '#/api/mes/pro/scanEvent'; |
| | | |
| | | interface ScanRecord extends MesProScanEventApi.ScanEvent { |
| | | key: number; |
| | | } |
| | | |
| | | const code = ref(''); |
| | | const deviceCode = ref(''); |
| | | const lineCode = ref(''); |
| | | const locationId = ref<number>(); |
| | | const areaId = ref<number>(); |
| | | const loading = ref(false); |
| | | const refreshing = ref(false); |
| | | const records = ref<ScanRecord[]>([]); |
| | | const dashboard = ref<MesProScanEventApi.Dashboard>({}); |
| | | let timer: ReturnType<typeof setInterval> | undefined; |
| | | |
| | | const latest = computed(() => dashboard.value.latest); |
| | | const successCount = computed(() => dashboard.value.success ?? records.value.filter((item) => item.status === 'SUCCESS').length); |
| | | const failedCount = computed(() => dashboard.value.failed ?? records.value.filter((item) => item.status === 'FAILED').length); |
| | | const isReady = computed(() => Boolean(deviceCode.value.trim() && locationId.value && areaId.value)); |
| | | const pageRoot = ref<HTMLElement>(); |
| | | let animationContext: gsap.Context | undefined; |
| | | let feedbackTimeline: gsap.core.Timeline | undefined; |
| | | |
| | | async function refresh() { |
| | | refreshing.value = true; |
| | | try { |
| | | const [summary, page] = await Promise.all([ |
| | | getScanDashboard(), |
| | | getScanEventPage({ pageNo: 1, pageSize: 20, deviceCode: deviceCode.value.trim() || undefined }), |
| | | ]); |
| | | dashboard.value = summary; |
| | | records.value = (page.list ?? []).map((item, index) => ({ ...item, key: item.id ?? index })); |
| | | } finally { |
| | | refreshing.value = false; |
| | | } |
| | | } |
| | | |
| | | async function submitScan() { |
| | | const bagCode = code.value.trim(); |
| | | if (!bagCode || !deviceCode.value.trim()) { |
| | | message.warning('请å
å¡«å设å¤ç¼ç ï¼åæ«æè¢ç '); |
| | | return; |
| | | } |
| | | if (!locationId.value || !areaId.value) { |
| | | message.warning('请å
é
ç½® WIP æååºåºååºä½'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | await inboundScanEvent({ |
| | | eventId: `${deviceCode.value.trim()}-${Date.now()}`, |
| | | bagCode, |
| | | quantity: 1, |
| | | deviceCode: deviceCode.value.trim(), |
| | | lineCode: lineCode.value.trim() || undefined, |
| | | locationId: locationId.value, |
| | | areaId: areaId.value, |
| | | }); |
| | | message.success(`è¢ç ${bagCode} 已宿å
¥åº`); |
| | | if (animationContext && pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { |
| | | feedbackTimeline?.kill(); |
| | | feedbackTimeline = gsap.timeline(); |
| | | feedbackTimeline |
| | | .to('.scan-ring', { scale: 1.16, duration: 0.18, ease: 'power2.out' }) |
| | | .to('.scan-ring', { scale: 1, duration: 0.5, ease: 'elastic.out(1, 0.45)' }) |
| | | .to('.scan-card', { boxShadow: '0 0 0 3px #b7ebd2, 0 12px 30px #20b87820', duration: 0.2 }, 0) |
| | | .to('.scan-card', { boxShadow: '0 3px 12px #243b5310', duration: 0.65 }); |
| | | } |
| | | code.value = ''; |
| | | await refresh(); |
| | | } catch (error) { |
| | | const response = error as { message?: string; msg?: string }; |
| | | message.error(response.message ?? response.msg ?? 'æ«ç å
¥åºå¤±è´¥ï¼è¯·æ¥çäºä»¶è®°å½'); |
| | | await refresh(); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function refreshLatest() { |
| | | if (deviceCode.value.trim()) await getLatestScanEvent(deviceCode.value.trim()); |
| | | await refresh(); |
| | | } |
| | | |
| | | onMounted(async () => { |
| | | await nextTick(); |
| | | if (pageRoot.value) { |
| | | animationContext = gsap.context(() => { |
| | | const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; |
| | | gsap.from('.page-header, .metric-card, .scan-card, .status-card, .records-card', { |
| | | autoAlpha: reduceMotion ? 1 : 0, |
| | | y: reduceMotion ? 0 : 14, |
| | | duration: reduceMotion ? 0 : 0.45, |
| | | stagger: reduceMotion ? 0 : 0.06, |
| | | ease: 'power2.out', |
| | | }); |
| | | if (!reduceMotion) { |
| | | gsap.to('.status-dot', { |
| | | scale: 1.12, |
| | | boxShadow: '0 0 0 5px #d9f7e9', |
| | | duration: 1.2, |
| | | repeat: -1, |
| | | yoyo: true, |
| | | ease: 'sine.inOut', |
| | | }); |
| | | gsap.to('.scan-ring', { |
| | | boxShadow: '0 0 0 10px #eaf3ff, 0 0 24px #1677ff30', |
| | | duration: 1.8, |
| | | repeat: -1, |
| | | yoyo: true, |
| | | ease: 'sine.inOut', |
| | | }); |
| | | } |
| | | }, pageRoot.value); |
| | | } |
| | | await refresh(); |
| | | timer = setInterval(refreshLatest, 15000); |
| | | }); |
| | | |
| | | onBeforeUnmount(() => { |
| | | if (timer) clearInterval(timer); |
| | | }); |
| | | |
| | | onUnmounted(() => { |
| | | feedbackTimeline?.kill(); |
| | | animationContext?.revert(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <div ref="pageRoot" class="scan-page"> |
| | | <header class="page-header"> |
| | | <div> |
| | | <div class="eyebrow">MES / ä¸è¢ä¸ç 管ç</div> |
| | | <h1>ç产线æ«ç å
¥åº</h1> |
| | | <p>宿¶æ¥æ¶è¾¹ç¼è®¾å¤è§£ç ç»æï¼èªå¨ç»è®°å
¥åºå¹¶åæ¥ WIP æååºå</p> |
| | | </div> |
| | | <div class="header-status"><span class="status-dot" aria-hidden="true" /><span>宿¶çæ§ä¸</span> <a-button type="text" :loading="refreshing" aria-label="å·æ°æ°æ®" @click="refresh"><IconifyIcon icon="ant-design:reload-outlined" /></a-button></div> |
| | | </header> |
| | | |
| | | <section class="metric-grid" aria-label="å
¥åºç»è®¡"> |
| | | <div class="metric-card"><div class="metric-icon blue"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><span>累计æ«ç äºä»¶</span><strong>{{ dashboard.total ?? 0 }}</strong><small>æç»æ¥æ¶</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon green"><IconifyIcon icon="ant-design:check-circle-outlined" /></div><div><span>æåå
¥åº</span><strong>{{ successCount }}</strong><small>å·²åå
¥åºåäºå¡</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon red"><IconifyIcon icon="ant-design:warning-outlined" /></div><div><span>失败äºä»¶</span><strong>{{ failedCount }}</strong><small>éå
³æ³¨å¼å¸¸è®°å½</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon orange"><IconifyIcon icon="ant-design:inbox-outlined" /></div><div><span>æè¿å
¥åºè¢ç </span><strong class="text-value">{{ latest?.bagCode ?? '-' }}</strong><small>{{ latest?.eventTime ?? 'ææ è®°å½' }}</small></div></div> |
| | | </section> |
| | | |
| | | <section class="main-grid"> |
| | | <a-card class="scan-card" :bordered="false"> |
| | | <template #title><div class="card-title"><IconifyIcon icon="ant-design:scan-outlined" />æ«ç å
¥åº <a-tag color="blue">èªå¨æ¨¡å¼</a-tag></div></template> |
| | | <div class="scan-panel"> |
| | | <div class="scan-intro"><div class="scan-ring"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><h2>çå¾
æ«ç </h2><p>å°è¢ç 对åå·¥ä¸ç¸æºï¼æä½¿ç¨æ«ç æªæ«æåå车确认</p></div></div> |
| | | <a-input-search v-model:value="code" class="scan-input" size="large" enter-button="确认å
¥åº" :loading="loading" :disabled="loading" placeholder="è¯·æ«æè¢ç ï¼æ«ç æªå车åèªå¨å
¥åº" @search="submitScan" /> |
| | | <div class="scan-hint"><IconifyIcon icon="ant-design:clock-circle-outlined" /> æå¡ç«¯è®°å½åç¡®å
¥åºæ¶é´ <span>·</span> <IconifyIcon icon="ant-design:inbox-outlined" /> æ¯è¢æ°éæ 1 计</div> |
| | | </div> |
| | | <a-divider /> |
| | | <a-form layout="vertical" class="config-form"> |
| | | <a-form-item label="设å¤ç¼ç " required><a-input v-model:value="deviceCode" prefix="设å¤" placeholder="ä¾å¦ï¼CAM-LINE-01" /></a-form-item> |
| | | <a-form-item label="产线ç¼ç "><a-input v-model:value="lineCode" placeholder="ä¾å¦ï¼LINE-A" /></a-form-item> |
| | | <a-form-item label="WIP æååºåº" required><a-input-number v-model:value="locationId" class="full-width" :min="1" placeholder="åºåºç¼å·" /></a-form-item> |
| | | <a-form-item label="WIP æååºä½" required><a-input-number v-model:value="areaId" class="full-width" :min="1" placeholder="åºä½ç¼å·" /></a-form-item> |
| | | </a-form> |
| | | </a-card> |
| | | |
| | | <a-card class="status-card" :bordered="false"> |
| | | <template #title><div class="card-title"><ApiOutlined />éé设å¤ç¶æ</div></template> |
| | | <div class="device-state"><span class="online-badge"><span class="status-dot" aria-hidden="true" /><span>{{ isReady ? '设å¤å·²å°±ç»ª' : 'çå¾
é
ç½®' }}</span></span><span class="muted">15 ç§èªå¨å·æ°</span></div> |
| | | <div class="status-list"><div><span>å½å设å¤</span><strong>{{ deviceCode || 'æªé
ç½®' }}</strong></div><div><span>ç产产线</span><strong>{{ lineCode || 'æªé
ç½®' }}</strong></div><div><span>æåä½ç½®</span><strong>{{ locationId && areaId ? `${locationId} åº / ${areaId} ä½` : 'æªé
ç½®' }}</strong></div><div><span>æè¿å
¥åºæ¶é´</span><strong>{{ latest?.eventTime || '-' }}</strong></div><div><span>æè¿çäº§æ¹æ¬¡</span><strong>{{ latest?.batchCode || '-' }}</strong></div></div> |
| | | <div class="sync-box"><IconifyIcon icon="ant-design:check-circle-outlined" /><div><strong>ä»å¨ç³»ç»åæ¥æ£å¸¸</strong><span>æ«ç äºä»¶ä¸åºåäºå¡å®æ¶åå
¥</span></div></div> |
| | | </a-card> |
| | | </section> |
| | | |
| | | <a-card class="records-card" :bordered="false"> |
| | | <template #title><div class="card-title"><IconifyIcon icon="ant-design:clock-circle-outlined" />æè¿æ«ç äºä»¶ <span class="record-count">æè¿ 20 æ¡</span></div></template> |
| | | <a-table :data-source="records" :pagination="false" :scroll="{ x: 1050 }" row-key="key" size="middle"> |
| | | <a-table-column key="bagCode" title="è¢ç " data-index="bagCode" /><a-table-column key="batchCode" title="çäº§æ¹æ¬¡" data-index="batchCode" /><a-table-column key="deviceCode" title="è®¾å¤ / 产线"><template #default="{ record }">{{ record.deviceCode || '-' }}<span class="sub-text">{{ record.lineCode || '-' }}</span></template></a-table-column> |
| | | <a-table-column key="status" title="ç¶æ"><template #default="{ record }"><a-tag :color="record.status === 'SUCCESS' ? 'success' : 'error'">{{ record.status === 'SUCCESS' ? 'æåå
¥åº' : '失败' }}</a-tag></template></a-table-column><a-table-column key="errorCode" title="é误ç " data-index="errorCode" /><a-table-column key="errorMessage" title="失败åå " data-index="errorMessage" /><a-table-column key="eventTime" title="å¤çæ¶é´" data-index="eventTime" /><a-table-column key="transactionId" title="åºåäºå¡" data-index="transactionId" /> |
| | | </a-table> |
| | | </a-card> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .scan-page { min-height: 100%; padding: 24px; background: #f4f7fb; color: #172033; } |
| | | .page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 22px; } |
| | | .eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } |
| | | h1 { margin: 6px 0; font-size: 28px; font-weight: 700; } .page-header p { margin: 0; color: #718096; } |
| | | .header-status, .device-state { display: flex; align-items: center; gap: 10px; color: #16835b; font-size: 13px; font-weight: 600; }.header-status :deep(.ant-btn) { color: #607089; } |
| | | .status-dot { display: inline-block; flex: 0 0 8px; width: 8px; height: 8px; margin: 0 2px 0 1px; border-radius: 50%; background: #20b878; box-shadow: 0 0 0 4px #d9f7e9; transform-origin: center; } |
| | | .metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 18px; }.metric-card { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px; background: #fff; border: 1px solid #e8edf5; border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.metric-card > div:last-child { min-width: 0; padding-top: 1px; }.metric-icon { flex: 0 0 42px; display: grid; place-items: center; width: 42px; height: 42px; margin-top: 1px; border-radius: 9px; font-size: 20px; }.metric-icon.blue { color: #1677ff; background: #eaf3ff; }.metric-icon.green { color: #17a673; background: #e6f8f0; }.metric-icon.red { color: #e05252; background: #fff0f0; }.metric-icon.orange { color: #d58a17; background: #fff6df; }.metric-card span, .metric-card small { display: block; color: #7b8799; font-size: 12px; }.metric-card strong { display: block; margin: 4px 0; font-size: 25px; line-height: 1.1; }.metric-card .text-value { overflow: hidden; max-width: 180px; text-overflow: ellipsis; white-space: nowrap; font-size: 18px; } |
| | | .main-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .8fr); gap: 18px; margin-bottom: 18px; }.scan-card, .status-card, .records-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 10px; min-height: 24px; font-weight: 700; }.card-title :deep(.iconify) { flex: 0 0 auto; margin-top: 1px; }.card-title :deep(.anticon) { color: #1677ff; }.card-title .ant-tag { margin-left: 4px; font-weight: 500; }.scan-panel { padding: 8px 0 4px; }.scan-intro { display: flex; align-items: center; gap: 16px; margin: 4px 0 22px; }.scan-ring { display: grid; place-items: center; width: 58px; height: 58px; margin: 4px 2px 4px 4px; border: 1px solid #b9d5ff; border-radius: 50%; color: #1677ff; background: #f1f7ff; font-size: 25px; }.scan-intro h2 { margin: 0 0 7px; font-size: 19px; line-height: 1.35; }.scan-intro p { margin: 0; color: #7b8799; font-size: 13px; }.scan-input :deep(input) { height: 50px; font-size: 16px; }.scan-input :deep(.ant-input-group-addon .ant-btn) { height: 50px; font-weight: 600; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 15px; padding-left: 2px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint span { margin: 0 2px; color: #b7c0cd; }.config-form { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: 18px; }.config-form :deep(.ant-form-item) { margin-bottom: 14px; }.full-width { width: 100%; } |
| | | .device-state { justify-content: space-between; margin-bottom: 20px; }.online-badge { display: inline-flex; align-items: center; gap: 10px; min-height: 24px; color: #16835b; white-space: nowrap; }.muted, .sub-text { color: #8793a5; font-size: 12px; }.status-list { border-top: 1px solid #edf0f5; }.status-list div { display: flex; justify-content: space-between; gap: 12px; padding: 13px 0; border-bottom: 1px solid #edf0f5; font-size: 13px; }.status-list span { color: #7b8799; }.status-list strong { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }.sync-box { display: flex; gap: 10px; margin-top: 20px; padding: 12px; border-radius: 7px; color: #16835b; background: #effaf5; }.sync-box div { display: flex; flex-direction: column; gap: 3px; }.sync-box span { color: #5f8b76; font-size: 12px; }.record-count { margin-left: 4px; color: #9aa5b5; font-size: 12px; font-weight: 400; }.sub-text { display: block; margin-top: 3px; } |
| | | @media (max-width: 900px) { .metric-grid { grid-template-columns: repeat(2, 1fr); }.main-grid { grid-template-columns: 1fr; } } |
| | | @media (max-width: 600px) { .scan-page { padding: 14px; }.page-header { display: block; }.header-status { margin-top: 14px; }.metric-grid { grid-template-columns: 1fr; }.config-form { grid-template-columns: 1fr; } h1 { font-size: 23px; } } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { gsap } from 'gsap'; |
| | | |
| | | import { |
| | | getLatestScanEvent, |
| | | getScanDashboard, |
| | | getScanEventPage, |
| | | inboundScanEvent, |
| | | type MesProScanEventApi, |
| | | } from '#/api/mes/pro/scanEvent'; |
| | | |
| | | interface ScanRecord extends MesProScanEventApi.ScanEvent { |
| | | key: number; |
| | | } |
| | | |
| | | const code = ref(''); |
| | | const deviceCode = ref(''); |
| | | const lineCode = ref(''); |
| | | const locationId = ref<number>(); |
| | | const areaId = ref<number>(); |
| | | const loading = ref(false); |
| | | const refreshing = ref(false); |
| | | const records = ref<ScanRecord[]>([]); |
| | | const dashboard = ref<MesProScanEventApi.Dashboard>({}); |
| | | let timer: ReturnType<typeof setInterval> | undefined; |
| | | |
| | | const latest = computed(() => dashboard.value.latest); |
| | | const successCount = computed(() => dashboard.value.success ?? records.value.filter((item) => item.status === 'SUCCESS').length); |
| | | const failedCount = computed(() => dashboard.value.failed ?? records.value.filter((item) => item.status === 'FAILED').length); |
| | | const isReady = computed(() => Boolean(deviceCode.value.trim() && locationId.value && areaId.value)); |
| | | const pageRoot = ref<HTMLElement>(); |
| | | let animationContext: gsap.Context | undefined; |
| | | let feedbackTimeline: gsap.core.Timeline | undefined; |
| | | |
| | | async function refresh() { |
| | | refreshing.value = true; |
| | | try { |
| | | const [summary, page] = await Promise.all([ |
| | | getScanDashboard(), |
| | | getScanEventPage({ pageNo: 1, pageSize: 20, deviceCode: deviceCode.value.trim() || undefined }), |
| | | ]); |
| | | dashboard.value = summary; |
| | | records.value = (page.list ?? []).map((item, index) => ({ ...item, key: item.id ?? index })); |
| | | } finally { |
| | | refreshing.value = false; |
| | | } |
| | | } |
| | | |
| | | async function submitScan() { |
| | | const bagCode = code.value.trim(); |
| | | if (!bagCode || !deviceCode.value.trim()) { |
| | | message.warning('请å
å¡«å设å¤ç¼ç ï¼åæ«æè¢ç '); |
| | | return; |
| | | } |
| | | if (!locationId.value || !areaId.value) { |
| | | message.warning('请å
é
ç½® WIP æååºåºååºä½'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | await inboundScanEvent({ |
| | | eventId: `${deviceCode.value.trim()}-${Date.now()}`, |
| | | bagCode, |
| | | quantity: 1, |
| | | deviceCode: deviceCode.value.trim(), |
| | | lineCode: lineCode.value.trim() || undefined, |
| | | locationId: locationId.value, |
| | | areaId: areaId.value, |
| | | }); |
| | | message.success(`è¢ç ${bagCode} 已宿å
¥åº`); |
| | | if (animationContext && pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { |
| | | feedbackTimeline?.kill(); |
| | | feedbackTimeline = gsap.timeline(); |
| | | feedbackTimeline |
| | | .to('.scan-ring', { scale: 1.16, duration: 0.18, ease: 'power2.out' }) |
| | | .to('.scan-ring', { scale: 1, duration: 0.5, ease: 'elastic.out(1, 0.45)' }) |
| | | .to('.scan-card', { boxShadow: '0 0 0 3px #b7ebd2, 0 12px 30px #20b87820', duration: 0.2 }, 0) |
| | | .to('.scan-card', { boxShadow: '0 3px 12px #243b5310', duration: 0.65 }); |
| | | } |
| | | code.value = ''; |
| | | await refresh(); |
| | | } catch (error) { |
| | | const response = error as { message?: string; msg?: string }; |
| | | message.error(response.message ?? response.msg ?? 'æ«ç å
¥åºå¤±è´¥ï¼è¯·æ¥çäºä»¶è®°å½'); |
| | | await refresh(); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function refreshLatest() { |
| | | if (deviceCode.value.trim()) await getLatestScanEvent(deviceCode.value.trim()); |
| | | await refresh(); |
| | | } |
| | | |
| | | onMounted(async () => { |
| | | await nextTick(); |
| | | if (pageRoot.value) { |
| | | animationContext = gsap.context(() => { |
| | | const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; |
| | | gsap.from('.page-header, .metric-card, .scan-card, .status-card, .records-card', { |
| | | autoAlpha: reduceMotion ? 1 : 0, |
| | | y: reduceMotion ? 0 : 14, |
| | | duration: reduceMotion ? 0 : 0.45, |
| | | stagger: reduceMotion ? 0 : 0.06, |
| | | ease: 'power2.out', |
| | | }); |
| | | if (!reduceMotion) { |
| | | gsap.to('.status-dot', { |
| | | scale: 1.12, |
| | | boxShadow: '0 0 0 5px #d9f7e9', |
| | | duration: 1.2, |
| | | repeat: -1, |
| | | yoyo: true, |
| | | ease: 'sine.inOut', |
| | | }); |
| | | gsap.to('.scan-ring', { |
| | | boxShadow: '0 0 0 10px #eaf3ff, 0 0 24px #1677ff30', |
| | | duration: 1.8, |
| | | repeat: -1, |
| | | yoyo: true, |
| | | ease: 'sine.inOut', |
| | | }); |
| | | } |
| | | }, pageRoot.value); |
| | | } |
| | | await refresh(); |
| | | timer = setInterval(refreshLatest, 15000); |
| | | }); |
| | | |
| | | onBeforeUnmount(() => { |
| | | if (timer) clearInterval(timer); |
| | | }); |
| | | |
| | | onUnmounted(() => { |
| | | feedbackTimeline?.kill(); |
| | | animationContext?.revert(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <div ref="pageRoot" class="scan-page"> |
| | | <header class="page-header"> |
| | | <div> |
| | | <div class="eyebrow">MES / ä¸è¢ä¸ç 管ç</div> |
| | | <h1>ç产线æ«ç å
¥åº</h1> |
| | | <p>宿¶æ¥æ¶è¾¹ç¼è®¾å¤è§£ç ç»æï¼èªå¨ç»è®°å
¥åºå¹¶åæ¥ WIP æååºå</p> |
| | | </div> |
| | | <div class="header-status"><span class="status-dot" aria-hidden="true" /><span>宿¶çæ§ä¸</span> <a-button type="text" :loading="refreshing" aria-label="å·æ°æ°æ®" @click="refresh"><IconifyIcon icon="ant-design:reload-outlined" /></a-button></div> |
| | | </header> |
| | | |
| | | <section class="metric-grid" aria-label="å
¥åºç»è®¡"> |
| | | <div class="metric-card"><div class="metric-icon blue"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><span>累计æ«ç äºä»¶</span><strong>{{ dashboard.total ?? 0 }}</strong><small>æç»æ¥æ¶</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon green"><IconifyIcon icon="ant-design:check-circle-outlined" /></div><div><span>æåå
¥åº</span><strong>{{ successCount }}</strong><small>å·²åå
¥åºåäºå¡</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon red"><IconifyIcon icon="ant-design:warning-outlined" /></div><div><span>失败äºä»¶</span><strong>{{ failedCount }}</strong><small>éå
³æ³¨å¼å¸¸è®°å½</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon orange"><IconifyIcon icon="ant-design:inbox-outlined" /></div><div><span>æè¿å
¥åºè¢ç </span><strong class="text-value">{{ latest?.bagCode ?? '-' }}</strong><small>{{ latest?.eventTime ?? 'ææ è®°å½' }}</small></div></div> |
| | | </section> |
| | | |
| | | <section class="main-grid"> |
| | | <a-card class="scan-card" :bordered="false"> |
| | | <template #title><div class="card-title"><IconifyIcon icon="ant-design:scan-outlined" />æ«ç å
¥åº <a-tag color="blue">èªå¨æ¨¡å¼</a-tag></div></template> |
| | | <div class="scan-panel"> |
| | | <div class="scan-intro"><div class="scan-ring"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><h2>çå¾
æ«ç </h2><p>å°è¢ç 对åå·¥ä¸ç¸æºï¼æä½¿ç¨æ«ç æªæ«æåå车确认</p></div></div> |
| | | <a-input-search v-model:value="code" class="scan-input" size="large" enter-button="确认å
¥åº" :loading="loading" :disabled="loading" placeholder="è¯·æ«æè¢ç ï¼æ«ç æªå车åèªå¨å
¥åº" @search="submitScan" /> |
| | | <div class="scan-hint"><IconifyIcon icon="ant-design:clock-circle-outlined" /> æå¡ç«¯è®°å½åç¡®å
¥åºæ¶é´ <span>·</span> <IconifyIcon icon="ant-design:inbox-outlined" /> æ¯è¢æ°éæ 1 计</div> |
| | | </div> |
| | | <a-divider /> |
| | | <a-form layout="vertical" class="config-form"> |
| | | <a-form-item label="设å¤ç¼ç " required><a-input v-model:value="deviceCode" prefix="设å¤" placeholder="ä¾å¦ï¼CAM-LINE-01" /></a-form-item> |
| | | <a-form-item label="产线ç¼ç "><a-input v-model:value="lineCode" placeholder="ä¾å¦ï¼LINE-A" /></a-form-item> |
| | | <a-form-item label="WIP æååºåº" required><a-input-number v-model:value="locationId" class="full-width" :min="1" placeholder="åºåºç¼å·" /></a-form-item> |
| | | <a-form-item label="WIP æååºä½" required><a-input-number v-model:value="areaId" class="full-width" :min="1" placeholder="åºä½ç¼å·" /></a-form-item> |
| | | </a-form> |
| | | </a-card> |
| | | |
| | | <a-card class="status-card" :bordered="false"> |
| | | <template #title><div class="card-title"><ApiOutlined />éé设å¤ç¶æ</div></template> |
| | | <div class="device-state"><span class="online-badge"><span class="status-dot" aria-hidden="true" /><span>{{ isReady ? '设å¤å·²å°±ç»ª' : 'çå¾
é
ç½®' }}</span></span><span class="muted">15 ç§èªå¨å·æ°</span></div> |
| | | <div class="status-list"><div><span>å½å设å¤</span><strong>{{ deviceCode || 'æªé
ç½®' }}</strong></div><div><span>ç产产线</span><strong>{{ lineCode || 'æªé
ç½®' }}</strong></div><div><span>æåä½ç½®</span><strong>{{ locationId && areaId ? `${locationId} åº / ${areaId} ä½` : 'æªé
ç½®' }}</strong></div><div><span>æè¿å
¥åºæ¶é´</span><strong>{{ latest?.eventTime || '-' }}</strong></div><div><span>æè¿çäº§æ¹æ¬¡</span><strong>{{ latest?.batchCode || '-' }}</strong></div></div> |
| | | <div class="sync-box"><IconifyIcon icon="ant-design:check-circle-outlined" /><div><strong>ä»å¨ç³»ç»åæ¥æ£å¸¸</strong><span>æ«ç äºä»¶ä¸åºåäºå¡å®æ¶åå
¥</span></div></div> |
| | | </a-card> |
| | | </section> |
| | | |
| | | <a-card class="records-card" :bordered="false"> |
| | | <template #title><div class="card-title"><IconifyIcon icon="ant-design:clock-circle-outlined" />æè¿æ«ç äºä»¶ <span class="record-count">æè¿ 20 æ¡</span></div></template> |
| | | <a-table :data-source="records" :pagination="false" :scroll="{ x: 1050 }" row-key="key" size="middle"> |
| | | <a-table-column key="bagCode" title="è¢ç " data-index="bagCode" /><a-table-column key="batchCode" title="çäº§æ¹æ¬¡" data-index="batchCode" /><a-table-column key="deviceCode" title="è®¾å¤ / 产线"><template #default="{ record }">{{ record.deviceCode || '-' }}<span class="sub-text">{{ record.lineCode || '-' }}</span></template></a-table-column> |
| | | <a-table-column key="status" title="ç¶æ"><template #default="{ record }"><a-tag :color="record.status === 'SUCCESS' ? 'success' : 'error'">{{ record.status === 'SUCCESS' ? 'æåå
¥åº' : '失败' }}</a-tag></template></a-table-column><a-table-column key="errorCode" title="é误ç " data-index="errorCode" /><a-table-column key="errorMessage" title="失败åå " data-index="errorMessage" /><a-table-column key="eventTime" title="å¤çæ¶é´" data-index="eventTime" /><a-table-column key="transactionId" title="åºåäºå¡" data-index="transactionId" /> |
| | | </a-table> |
| | | </a-card> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .scan-page { min-height: 100%; padding: 24px; background: #f4f7fb; color: #172033; } |
| | | .page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 22px; } |
| | | .eyebrow { color: #1677ff; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } |
| | | h1 { margin: 6px 0; font-size: 28px; font-weight: 700; } .page-header p { margin: 0; color: #718096; } |
| | | .header-status, .device-state { display: flex; align-items: center; gap: 10px; color: #16835b; font-size: 13px; font-weight: 600; }.header-status :deep(.ant-btn) { color: #607089; } |
| | | .status-dot { display: inline-block; flex: 0 0 8px; width: 8px; height: 8px; margin: 0 2px 0 1px; border-radius: 50%; background: #20b878; box-shadow: 0 0 0 4px #d9f7e9; transform-origin: center; } |
| | | .metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 18px; }.metric-card { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px; background: #fff; border: 1px solid #e8edf5; border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.metric-card > div:last-child { min-width: 0; padding-top: 1px; }.metric-icon { flex: 0 0 42px; display: grid; place-items: center; width: 42px; height: 42px; margin-top: 1px; border-radius: 9px; font-size: 20px; }.metric-icon.blue { color: #1677ff; background: #eaf3ff; }.metric-icon.green { color: #17a673; background: #e6f8f0; }.metric-icon.red { color: #e05252; background: #fff0f0; }.metric-icon.orange { color: #d58a17; background: #fff6df; }.metric-card span, .metric-card small { display: block; color: #7b8799; font-size: 12px; }.metric-card strong { display: block; margin: 4px 0; font-size: 25px; line-height: 1.1; }.metric-card .text-value { overflow: hidden; max-width: 180px; text-overflow: ellipsis; white-space: nowrap; font-size: 18px; } |
| | | .main-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .8fr); gap: 18px; margin-bottom: 18px; }.scan-card, .status-card, .records-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 10px; min-height: 24px; font-weight: 700; }.card-title :deep(.iconify) { flex: 0 0 auto; margin-top: 1px; }.card-title :deep(.anticon) { color: #1677ff; }.card-title .ant-tag { margin-left: 4px; font-weight: 500; }.scan-panel { padding: 8px 0 4px; }.scan-intro { display: flex; align-items: center; gap: 16px; margin: 4px 0 22px; }.scan-ring { display: grid; place-items: center; width: 58px; height: 58px; margin: 4px 2px 4px 4px; border: 1px solid #b9d5ff; border-radius: 50%; color: #1677ff; background: #f1f7ff; font-size: 25px; }.scan-intro h2 { margin: 0 0 7px; font-size: 19px; line-height: 1.35; }.scan-intro p { margin: 0; color: #7b8799; font-size: 13px; }.scan-input :deep(input) { height: 50px; font-size: 16px; }.scan-input :deep(.ant-input-group-addon .ant-btn) { height: 50px; font-weight: 600; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 15px; padding-left: 2px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint span { margin: 0 2px; color: #b7c0cd; }.config-form { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: 18px; }.config-form :deep(.ant-form-item) { margin-bottom: 14px; }.full-width { width: 100%; } |
| | | .device-state { justify-content: space-between; margin-bottom: 20px; }.online-badge { display: inline-flex; align-items: center; gap: 10px; min-height: 24px; color: #16835b; white-space: nowrap; }.muted, .sub-text { color: #8793a5; font-size: 12px; }.status-list { border-top: 1px solid #edf0f5; }.status-list div { display: flex; justify-content: space-between; gap: 12px; padding: 13px 0; border-bottom: 1px solid #edf0f5; font-size: 13px; }.status-list span { color: #7b8799; }.status-list strong { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }.sync-box { display: flex; gap: 10px; margin-top: 20px; padding: 12px; border-radius: 7px; color: #16835b; background: #effaf5; }.sync-box div { display: flex; flex-direction: column; gap: 3px; }.sync-box span { color: #5f8b76; font-size: 12px; }.record-count { margin-left: 4px; color: #9aa5b5; font-size: 12px; font-weight: 400; }.sub-text { display: block; margin-top: 3px; } |
| | | @media (max-width: 900px) { .metric-grid { grid-template-columns: repeat(2, 1fr); }.main-grid { grid-template-columns: 1fr; } } |
| | | @media (max-width: 600px) { .scan-page { padding: 14px; }.page-header { display: block; }.header-status { margin-top: 14px; }.metric-grid { grid-template-columns: 1fr; }.config-form { grid-template-columns: 1fr; } h1 { font-size: 23px; } } |
| | | </style> |