|
|
@@ -1,8 +1,8 @@
|
|
|
-import React, { useState, useEffect, useRef } from 'react';
|
|
|
+import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
import { Modal, message } from 'antd';
|
|
|
import { Clock } from 'lucide-react';
|
|
|
import { slotApi, LockCabinetSlotVO, SlotPageParam } from '../../api/lockCabinet/slots';
|
|
|
-import { systemAttributeApi } from '../../api/systemAttribute';
|
|
|
+import { lockCabinetApi, LockCabinetVO } from '../../api/lockCabinet';
|
|
|
import { dateFormatter } from '../../utils/formatTime';
|
|
|
|
|
|
interface MapDataProps {
|
|
|
@@ -10,15 +10,49 @@ interface MapDataProps {
|
|
|
}
|
|
|
|
|
|
export default function MapData({ cabinetId }: MapDataProps) {
|
|
|
- const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
+ const wrapperRef = useRef<HTMLDivElement>(null);
|
|
|
+ const [cabinetInfo, setCabinetInfo] = useState<LockCabinetVO | null>(null);
|
|
|
const [slotData, setSlotData] = useState<LockCabinetSlotVO[]>([]);
|
|
|
const [updateTime, setUpdateTime] = useState<string | null>(null);
|
|
|
const [dialogVisible, setDialogVisible] = useState(false);
|
|
|
const [errorInfo, setErrorInfo] = useState('');
|
|
|
- const [cachedImages, setCachedImages] = useState<Record<string, HTMLImageElement>>({});
|
|
|
- const [cachedResults, setCachedResults] = useState<Record<string, string>>({});
|
|
|
+
|
|
|
+ // 3D 控制状态
|
|
|
+ const [isDragging, setIsDragging] = useState(false);
|
|
|
+ const previousMousePosition = useRef({ x: 0, y: 0 });
|
|
|
+ const rotation = useRef({ x: -5, y: -25 });
|
|
|
+ const scale = useRef(1);
|
|
|
+
|
|
|
+ // 统计数据
|
|
|
+ const stats = React.useMemo(() => {
|
|
|
+ const total = slotData.length;
|
|
|
+ const occupied = slotData.filter(s => s.isOccupied === '1').length;
|
|
|
+ const free = total - occupied;
|
|
|
+ const abnormal = slotData.filter(s => s.status === '1').length;
|
|
|
+ return { total, occupied, free, abnormal };
|
|
|
+ }, [slotData]);
|
|
|
+
|
|
|
+ // 右侧两块:随机展示(每次刷新/重新进入会变化)
|
|
|
+ const randomRightStats = React.useMemo(() => {
|
|
|
+ const total = stats.total > 0 ? stats.total : 50;
|
|
|
+ const occupied = Math.floor(Math.random() * (total + 1));
|
|
|
+ const abnormal = Math.floor(Math.random() * (occupied + 1));
|
|
|
+ return { occupied, abnormal };
|
|
|
+ }, [stats.total]);
|
|
|
+
|
|
|
+ // 获取机柜详情
|
|
|
+ const getCabinetInfo = async () => {
|
|
|
+ if (!cabinetId) return;
|
|
|
+ try {
|
|
|
+ const info = await lockCabinetApi.selectIsLockCabinetById(Number(cabinetId));
|
|
|
+ setCabinetInfo(info);
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('获取机柜信息失败:', error);
|
|
|
+ message.error(error.message || '获取机柜信息失败');
|
|
|
+ }
|
|
|
+ };
|
|
|
|
|
|
- // 获取数据
|
|
|
+ // 获取槽位数据
|
|
|
const getData = async () => {
|
|
|
if (!cabinetId) return;
|
|
|
|
|
|
@@ -35,213 +69,591 @@ export default function MapData({ cabinetId }: MapDataProps) {
|
|
|
if (slots.length > 0 && slots[0].createTime) {
|
|
|
setUpdateTime(dateFormatter(slots[0].createTime));
|
|
|
}
|
|
|
-
|
|
|
- // 获取图标配置
|
|
|
- const icons = [
|
|
|
- 'icon.locker.normal',
|
|
|
- 'icon.locker.out',
|
|
|
- 'icon.padlock.normal',
|
|
|
- 'icon.padlock.out',
|
|
|
- 'icon.locker.exception'
|
|
|
- ];
|
|
|
-
|
|
|
- const results: Record<string, string> = {};
|
|
|
- for (const key of icons) {
|
|
|
- try {
|
|
|
- const attr = await systemAttributeApi.getIsSystemAttributeByKey(key);
|
|
|
- results[key] = attr.sysAttrValue || '';
|
|
|
- } catch (error) {
|
|
|
- console.error(`获取图标配置失败: ${key}`, error);
|
|
|
- }
|
|
|
- }
|
|
|
- setCachedResults(results);
|
|
|
-
|
|
|
- // 预加载图片
|
|
|
- await preloadImages(results);
|
|
|
} catch (error: any) {
|
|
|
console.error('获取数据失败:', error);
|
|
|
message.error(error.message || '获取数据失败');
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- // 预加载图片
|
|
|
- const preloadImages = async (results: Record<string, string>) => {
|
|
|
- const urls = Object.values(results).filter(url => url);
|
|
|
- const images: Record<string, HTMLImageElement> = {};
|
|
|
-
|
|
|
- for (const url of urls) {
|
|
|
- if (cachedImages[url]) {
|
|
|
- images[url] = cachedImages[url];
|
|
|
- } else {
|
|
|
- try {
|
|
|
- const img = await loadImage(url);
|
|
|
- images[url] = img;
|
|
|
- } catch (error) {
|
|
|
- console.error(`加载图片失败: ${url}`, error);
|
|
|
- }
|
|
|
+ // 3D 拖拽处理
|
|
|
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
|
+ setIsDragging(true);
|
|
|
+ previousMousePosition.current = { x: e.clientX, y: e.clientY };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
|
+ if (!isDragging) return;
|
|
|
+
|
|
|
+ const deltaX = e.clientX - previousMousePosition.current.x;
|
|
|
+ const deltaY = e.clientY - previousMousePosition.current.y;
|
|
|
+
|
|
|
+ rotation.current.y += deltaX * 0.5;
|
|
|
+ rotation.current.x -= deltaY * 0.5;
|
|
|
+ rotation.current.x = Math.max(-45, Math.min(45, rotation.current.x));
|
|
|
+
|
|
|
+ if (wrapperRef.current) {
|
|
|
+ wrapperRef.current.style.transform = `rotateX(${rotation.current.x}deg) rotateY(${rotation.current.y}deg) scale(${scale.current})`;
|
|
|
+ }
|
|
|
+
|
|
|
+ previousMousePosition.current = { x: e.clientX, y: e.clientY };
|
|
|
+ }, [isDragging]);
|
|
|
+
|
|
|
+ const handleMouseUp = useCallback(() => {
|
|
|
+ setIsDragging(false);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 滚轮缩放
|
|
|
+ const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
|
+ e.preventDefault();
|
|
|
+ scale.current += e.deltaY * -0.001;
|
|
|
+ scale.current = Math.max(0.3, Math.min(1.5, scale.current));
|
|
|
+ if (wrapperRef.current) {
|
|
|
+ wrapperRef.current.style.transform = `rotateX(${rotation.current.x}deg) rotateY(${rotation.current.y}deg) scale(${scale.current})`;
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 触摸支持
|
|
|
+ const touchStartDistance = useRef(0);
|
|
|
+ const initialScale = useRef(1);
|
|
|
+
|
|
|
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
|
+ if (e.touches.length === 1) {
|
|
|
+ setIsDragging(true);
|
|
|
+ previousMousePosition.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
|
+ } else if (e.touches.length === 2) {
|
|
|
+ touchStartDistance.current = Math.hypot(
|
|
|
+ e.touches[0].clientX - e.touches[1].clientX,
|
|
|
+ e.touches[0].clientY - e.touches[1].clientY
|
|
|
+ );
|
|
|
+ initialScale.current = scale.current;
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
|
+ e.preventDefault();
|
|
|
+ if (e.touches.length === 1 && isDragging) {
|
|
|
+ const deltaX = e.touches[0].clientX - previousMousePosition.current.x;
|
|
|
+ const deltaY = e.touches[0].clientY - previousMousePosition.current.y;
|
|
|
+
|
|
|
+ rotation.current.y += deltaX * 0.5;
|
|
|
+ rotation.current.x -= deltaY * 0.5;
|
|
|
+ rotation.current.x = Math.max(-45, Math.min(45, rotation.current.x));
|
|
|
+
|
|
|
+ if (wrapperRef.current) {
|
|
|
+ wrapperRef.current.style.transform = `rotateX(${rotation.current.x}deg) rotateY(${rotation.current.y}deg) scale(${scale.current})`;
|
|
|
+ }
|
|
|
+ previousMousePosition.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
|
+ } else if (e.touches.length === 2) {
|
|
|
+ const currentDistance = Math.hypot(
|
|
|
+ e.touches[0].clientX - e.touches[1].clientX,
|
|
|
+ e.touches[0].clientY - e.touches[1].clientY
|
|
|
+ );
|
|
|
+ scale.current = initialScale.current * (currentDistance / touchStartDistance.current);
|
|
|
+ scale.current = Math.max(0.3, Math.min(1.5, scale.current));
|
|
|
+ if (wrapperRef.current) {
|
|
|
+ wrapperRef.current.style.transform = `rotateX(${rotation.current.x}deg) rotateY(${rotation.current.y}deg) scale(${scale.current})`;
|
|
|
}
|
|
|
}
|
|
|
+ }, [isDragging]);
|
|
|
|
|
|
- setCachedImages(prev => ({ ...prev, ...images }));
|
|
|
- };
|
|
|
+ const handleTouchEnd = useCallback(() => {
|
|
|
+ setIsDragging(false);
|
|
|
+ }, []);
|
|
|
|
|
|
- // 加载图片
|
|
|
- const loadImage = (url: string): Promise<HTMLImageElement> => {
|
|
|
- return new Promise((resolve, reject) => {
|
|
|
- const img = new Image();
|
|
|
- img.crossOrigin = 'Anonymous';
|
|
|
- img.src = url;
|
|
|
- img.onload = () => resolve(img);
|
|
|
- img.onerror = reject;
|
|
|
- });
|
|
|
+ // 获取槽位状态
|
|
|
+ const getSlotStatus = (slot: LockCabinetSlotVO) => {
|
|
|
+ if (slot.status === '1') return '异常';
|
|
|
+ if (slot.isOccupied === '1') return '已占用';
|
|
|
+ return '空闲';
|
|
|
};
|
|
|
|
|
|
- // 绘制图形
|
|
|
- const drawCanvas = () => {
|
|
|
- const canvas = canvasRef.current;
|
|
|
- if (!canvas) return;
|
|
|
-
|
|
|
- const ctx = canvas.getContext('2d');
|
|
|
- if (!ctx) return;
|
|
|
+ // 获取槽位类型名称
|
|
|
+ const getSlotTypeName = (slot: LockCabinetSlotVO) => {
|
|
|
+ return slot.slotType === '0' ? '钥匙' : '挂锁';
|
|
|
+ };
|
|
|
|
|
|
- // 清空画布
|
|
|
- ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
+ useEffect(() => {
|
|
|
+ if (cabinetId) {
|
|
|
+ getCabinetInfo();
|
|
|
+ getData();
|
|
|
+ }
|
|
|
+ }, [cabinetId]);
|
|
|
|
|
|
- // 按行分组
|
|
|
+ // 按行分组槽位数据(用于 3D 显示)
|
|
|
+ const groupedSlots = React.useMemo(() => {
|
|
|
const grouped: Record<number, LockCabinetSlotVO[]> = {};
|
|
|
slotData.forEach(slot => {
|
|
|
const row = slot.row || 0;
|
|
|
if (!grouped[row]) grouped[row] = [];
|
|
|
grouped[row].push(slot);
|
|
|
});
|
|
|
+ return grouped;
|
|
|
+ }, [slotData]);
|
|
|
+
|
|
|
+ // 获取钥匙槽位(前4个)
|
|
|
+ const keySlots = React.useMemo(() => {
|
|
|
+ return slotData.filter(s => s.slotType === '0').slice(0, 4);
|
|
|
+ }, [slotData]);
|
|
|
+
|
|
|
+ // 获取挂锁槽位(按行分组,最多5行,每行10个)
|
|
|
+ const lockSlots = React.useMemo(() => {
|
|
|
+ const locks = slotData.filter(s => s.slotType === '1');
|
|
|
+ const rows: LockCabinetSlotVO[][] = [];
|
|
|
+ for (let i = 0; i < 5; i++) {
|
|
|
+ rows.push(locks.slice(i * 10, (i + 1) * 10));
|
|
|
+ }
|
|
|
+ return rows;
|
|
|
+ }, [slotData]);
|
|
|
|
|
|
- const rows = Object.keys(grouped).map(Number).sort((a, b) => a - b);
|
|
|
- const startY = 20;
|
|
|
- const rowHeight = 120;
|
|
|
- const rowGap = 20;
|
|
|
- const boxWidth = 860;
|
|
|
- const centerX = canvas.width / 2;
|
|
|
- const boxStartX = centerX - boxWidth / 2;
|
|
|
-
|
|
|
- rows.forEach((rowKey, rowIndex) => {
|
|
|
- const rowSlots = grouped[rowKey];
|
|
|
-
|
|
|
- // 绘制行容器
|
|
|
- ctx.strokeStyle = 'black';
|
|
|
- ctx.lineWidth = 2;
|
|
|
- ctx.strokeRect(
|
|
|
- boxStartX,
|
|
|
- startY + rowIndex * (rowHeight + rowGap),
|
|
|
- boxWidth,
|
|
|
- rowHeight
|
|
|
- );
|
|
|
+ return (
|
|
|
+ <div className="flex gap-6 h-full min-h-0">
|
|
|
+ {/* 左侧:3D 机柜 */}
|
|
|
+ <div className="flex-1 min-h-0 bg-white rounded-lg shadow-sm p-4 overflow-hidden relative">
|
|
|
+ <div
|
|
|
+ className="w-full h-full flex items-center justify-center"
|
|
|
+ style={{ perspective: '2000px', perspectiveOrigin: '50% 50%' }}
|
|
|
+ onMouseDown={handleMouseDown}
|
|
|
+ onMouseMove={handleMouseMove}
|
|
|
+ onMouseUp={handleMouseUp}
|
|
|
+ onMouseLeave={handleMouseUp}
|
|
|
+ onWheel={handleWheel}
|
|
|
+ onTouchStart={handleTouchStart}
|
|
|
+ onTouchMove={handleTouchMove}
|
|
|
+ onTouchEnd={handleTouchEnd}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ ref={wrapperRef}
|
|
|
+ className="relative"
|
|
|
+ style={{
|
|
|
+ transformStyle: 'preserve-3d',
|
|
|
+ transform: `rotateX(${rotation.current.x}deg) rotateY(${rotation.current.y}deg) scale(${scale.current})`,
|
|
|
+ transition: 'transform 0.1s ease-out',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 地面阴影 */}
|
|
|
+ <div
|
|
|
+ className="absolute"
|
|
|
+ style={{
|
|
|
+ left: '50%',
|
|
|
+ top: '50%',
|
|
|
+ transform: 'translateX(-50%) translateY(calc(325px + 60px)) rotateX(90deg)',
|
|
|
+ width: '520px',
|
|
|
+ height: '240px',
|
|
|
+ background: 'radial-gradient(ellipse at center, rgba(0,0,0,0.2) 0%, transparent 65%)',
|
|
|
+ filter: 'blur(20px)',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 3D 机柜容器 */}
|
|
|
+ <div
|
|
|
+ className="relative"
|
|
|
+ style={{
|
|
|
+ width: '360px',
|
|
|
+ height: '650px',
|
|
|
+ transformStyle: 'preserve-3d',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 背面 */}
|
|
|
+ <div
|
|
|
+ className="absolute"
|
|
|
+ style={{
|
|
|
+ width: '360px',
|
|
|
+ height: '650px',
|
|
|
+ background: 'linear-gradient(180deg, #808080 0%, #707070 50%, #606060 100%)',
|
|
|
+ borderRadius: '8px',
|
|
|
+ border: '3px solid #555',
|
|
|
+ transform: 'translateZ(-150px)',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 左侧面 */}
|
|
|
+ <div
|
|
|
+ className="absolute"
|
|
|
+ style={{
|
|
|
+ width: '150px',
|
|
|
+ height: '650px',
|
|
|
+ background: 'linear-gradient(90deg, #989898 0%, #a8a8a8 30%, #b8b8b8 70%, #c8c8c8 100%)',
|
|
|
+ left: '-150px',
|
|
|
+ top: '0',
|
|
|
+ transformOrigin: 'right center',
|
|
|
+ transform: 'rotateY(-90deg)',
|
|
|
+ borderTop: '3px solid #b0b0b0',
|
|
|
+ borderBottom: '3px solid #808080',
|
|
|
+ borderLeft: '3px solid #888',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 右侧面 */}
|
|
|
+ <div
|
|
|
+ className="absolute"
|
|
|
+ style={{
|
|
|
+ width: '150px',
|
|
|
+ height: '650px',
|
|
|
+ background: 'linear-gradient(270deg, #888888 0%, #989898 30%, #a8a8a8 70%, #b0b0b0 100%)',
|
|
|
+ left: '360px',
|
|
|
+ top: '0',
|
|
|
+ transformOrigin: 'left center',
|
|
|
+ transform: 'rotateY(90deg)',
|
|
|
+ borderTop: '3px solid #a0a0a0',
|
|
|
+ borderBottom: '3px solid #777',
|
|
|
+ borderRight: '3px solid #777',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 顶部 */}
|
|
|
+ <div
|
|
|
+ className="absolute"
|
|
|
+ style={{
|
|
|
+ width: '360px',
|
|
|
+ height: '150px',
|
|
|
+ background: 'linear-gradient(180deg, #d0d0d0 0%, #c8c8c8 30%, #c0c0c0 70%, #b8b8b8 100%)',
|
|
|
+ left: '0',
|
|
|
+ top: '-150px',
|
|
|
+ transformOrigin: 'center bottom',
|
|
|
+ transform: 'rotateX(90deg)',
|
|
|
+ borderLeft: '3px solid #b8b8b8',
|
|
|
+ borderRight: '3px solid #a8a8a8',
|
|
|
+ borderTop: '3px solid #909090',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 底部 */}
|
|
|
+ <div
|
|
|
+ className="absolute"
|
|
|
+ style={{
|
|
|
+ width: '360px',
|
|
|
+ height: '150px',
|
|
|
+ background: 'linear-gradient(0deg, #606060 0%, #707070 30%, #787878 70%, #808080 100%)',
|
|
|
+ left: '0',
|
|
|
+ top: '650px',
|
|
|
+ transformOrigin: 'center top',
|
|
|
+ transform: 'rotateX(-90deg)',
|
|
|
+ borderLeft: '3px solid #707070',
|
|
|
+ borderRight: '3px solid #606060',
|
|
|
+ borderBottom: '3px solid #505050',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 正面 */}
|
|
|
+ <div
|
|
|
+ className="absolute"
|
|
|
+ style={{
|
|
|
+ width: '360px',
|
|
|
+ height: '650px',
|
|
|
+ background: 'linear-gradient(180deg, #d8d8d8 0%, #c8c8c8 50%, #b8b8b8 100%)',
|
|
|
+ borderRadius: '8px',
|
|
|
+ border: '3px solid #a0a0a0',
|
|
|
+ boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.5), inset 0 -1px 0 rgba(0,0,0,0.2)',
|
|
|
+ transform: 'translateZ(0)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 顶部挂钩 */}
|
|
|
+ <div
|
|
|
+ className="absolute"
|
|
|
+ style={{
|
|
|
+ top: '-15px',
|
|
|
+ left: '30px',
|
|
|
+ width: '30px',
|
|
|
+ height: '25px',
|
|
|
+ background: 'linear-gradient(180deg, #999 0%, #666 100%)',
|
|
|
+ borderRadius: '50% 50% 0 0',
|
|
|
+ transform: 'translateZ(5px)',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <div
|
|
|
+ className="absolute"
|
|
|
+ style={{
|
|
|
+ top: '-15px',
|
|
|
+ right: '30px',
|
|
|
+ width: '30px',
|
|
|
+ height: '25px',
|
|
|
+ background: 'linear-gradient(180deg, #999 0%, #666 100%)',
|
|
|
+ borderRadius: '50% 50% 0 0',
|
|
|
+ transform: 'translateZ(5px)',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 头部区域 */}
|
|
|
+ <div className="relative z-10 px-5 pt-4 pb-2 flex justify-between items-center">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <div className="w-12 h-10 relative">
|
|
|
+ <svg viewBox="0 0 50 40" className="w-full h-full" style={{ filter: 'drop-shadow(2px 2px 2px rgba(0,0,0,0.3))' }}>
|
|
|
+ <path d="M5 20 Q5 8 20 8 L35 8 Q45 8 45 18 Q45 28 35 28 L25 28 Q20 28 20 33 Q20 38 25 38"
|
|
|
+ stroke="#c41e3a" strokeWidth="4" fill="none" strokeLinecap="round"/>
|
|
|
+ <circle cx="25" cy="38" r="3" fill="#c41e3a"/>
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <span className="text-red-600 text-xl font-bold tracking-wider" style={{ textShadow: '2px 2px 4px rgba(0,0,0,0.2)' }}>
|
|
|
+ BOZZYS
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div className="text-red-600 text-lg font-bold" style={{ textShadow: '2px 2px 4px rgba(0,0,0,0.2)' }}>
|
|
|
+ LOTO锁控系统
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 主面板 */}
|
|
|
+ <div className="mx-4 mt-2 mb-2 rounded bg-gradient-to-b from-gray-200 to-gray-300 p-4 shadow-inner" style={{ transform: 'translateZ(5px)' }}>
|
|
|
+ {/* 屏幕区域 */}
|
|
|
+ <div className="flex justify-center mb-4">
|
|
|
+ <div
|
|
|
+ className="relative rounded-lg p-4 pb-11"
|
|
|
+ style={{
|
|
|
+ background: 'linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 50%, #0a0a0a 100%)',
|
|
|
+ boxShadow: '0 6px 20px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.1), inset 0 -1px 0 rgba(0,0,0,0.3)',
|
|
|
+ transform: 'translateZ(8px)',
|
|
|
+ border: '2px solid #333',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 屏幕 */}
|
|
|
+ <div
|
|
|
+ className="rounded border-2 border-gray-600 overflow-hidden"
|
|
|
+ style={{
|
|
|
+ width: '320px',
|
|
|
+ height: '180px',
|
|
|
+ background: '#1a3a5c',
|
|
|
+ boxShadow: '0 2px 8px rgba(0,0,0,0.4), inset 0 0 30px rgba(0,100,200,0.3)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div className="w-full h-full bg-gradient-to-b from-blue-900 to-blue-800 p-2">
|
|
|
+ <div className="flex justify-between text-white text-xs mb-2">
|
|
|
+ <span>设备状态监控</span>
|
|
|
+ <span>{new Date().toLocaleString('zh-CN')}</span>
|
|
|
+ </div>
|
|
|
+ <div className="bg-white/10 rounded p-1">
|
|
|
+ {slotData.slice(0, 5).map((slot, idx) => (
|
|
|
+ <div key={idx} className="flex justify-between text-cyan-400 text-xs py-1 px-1 border-b border-white/10 last:border-0">
|
|
|
+ <span>{slot.slotName || slot.slotCode || idx + 1}</span>
|
|
|
+ <span style={{ color: slot.status === '1' ? '#ff4444' : slot.isOccupied === '1' ? '#44ff44' : '#ffff44' }}>
|
|
|
+ {getSlotStatus(slot)}
|
|
|
+ </span>
|
|
|
+ <span>{slot.slotName || '-'}</span>
|
|
|
+ <span>{slot.updateTime ? new Date(slot.updateTime).toLocaleTimeString('zh-CN') : '-'}</span>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {/* 底部刷卡和指纹 */}
|
|
|
+ <div className="absolute bottom-2 right-3 flex items-center gap-2">
|
|
|
+ <div className="w-12 h-7 rounded bg-gradient-to-b from-gray-600 to-gray-800 border border-gray-700 flex items-center justify-center">
|
|
|
+ <div className="w-5 h-4 border border-gray-600 rounded bg-gradient-to-b from-gray-700 to-gray-900" />
|
|
|
+ </div>
|
|
|
+ <div className="w-7 h-7 rounded bg-gradient-to-b from-gray-600 to-gray-800 border border-gray-700 flex items-center justify-center">
|
|
|
+ <div className="w-4 h-4 border-2 border-gray-600 rounded-full" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 抽屉 */}
|
|
|
+ <div className="h-9 mb-2 rounded bg-gradient-to-b from-gray-100 to-gray-200 border border-gray-400 flex items-center justify-center shadow-md" style={{ transform: 'translateZ(12px)' }}>
|
|
|
+ <div className="w-10 h-2 rounded bg-gradient-to-b from-gray-400 to-gray-600 shadow-sm" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 钥匙仓区域 */}
|
|
|
+ <div className="py-4 border-b-2 border-gray-400" style={{ transform: 'translateZ(5px)' }}>
|
|
|
+ <div className="text-center text-xs text-gray-600 font-bold mb-2">钥匙仓</div>
|
|
|
+ <div className="flex justify-center gap-5">
|
|
|
+ {keySlots.map((slot, idx) => (
|
|
|
+ <div key={idx} className="relative">
|
|
|
+ <span className="absolute -top-5 left-1/2 -translate-x-1/2 text-xs font-bold text-gray-600">{idx + 1}</span>
|
|
|
+ <div
|
|
|
+ className="w-12 h-12 rounded-full border-3 border-gray-600 flex items-center justify-center shadow-lg"
|
|
|
+ style={{
|
|
|
+ background: 'linear-gradient(180deg, #3a3a3a 0%, #1a1a1a 100%)',
|
|
|
+ boxShadow: '0 4px 8px rgba(0,0,0,0.4), inset 0 2px 4px rgba(0,0,0,0.5)',
|
|
|
+ transform: 'translateZ(10px)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div className="w-8 h-8 rounded-full border-2 border-gray-700 bg-gradient-to-b from-gray-800 to-black" />
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ className={`absolute -bottom-2 left-1/2 -translate-x-1/2 w-2 h-2 rounded-full ${
|
|
|
+ slot.status === '1' ? 'bg-red-500' : slot.isOccupied === '1' ? 'bg-green-500' : 'bg-gray-500'
|
|
|
+ }`}
|
|
|
+ style={{
|
|
|
+ boxShadow: slot.status === '1' ? '0 0 6px #ff0000' : slot.isOccupied === '1' ? '0 0 6px #00ff00' : 'none',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 锁仓区域 */}
|
|
|
+ <div className="py-2" style={{ transform: 'translateZ(3px)' }}>
|
|
|
+ <div className="text-center text-xs text-gray-600 font-bold mb-2 mt-2">锁仓</div>
|
|
|
+ {lockSlots.map((row, rowIdx) => (
|
|
|
+ <div key={rowIdx} className="flex justify-center gap-1.5 mb-2 mt-4 first:mt-5">
|
|
|
+ {row.map((slot, slotIdx) => (
|
|
|
+ <div key={slotIdx} className="relative">
|
|
|
+ <span className="absolute -top-4 left-1/2 -translate-x-1/2 text-xs font-bold text-gray-600">
|
|
|
+ {rowIdx * 10 + slotIdx + 1}
|
|
|
+ </span>
|
|
|
+ <div
|
|
|
+ className="w-9 h-11 rounded flex flex-col items-center p-1 border border-gray-600 shadow-md"
|
|
|
+ style={{
|
|
|
+ background: 'linear-gradient(180deg, #4a4a4a 0%, #2a2a2a 100%)',
|
|
|
+ boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.3)',
|
|
|
+ transform: 'translateZ(8px)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ className={`w-2 h-2 rounded-full mb-1 ${
|
|
|
+ slot.status === '1' ? 'bg-red-500' : slot.isOccupied === '1' ? 'bg-red-500' : 'bg-gray-600'
|
|
|
+ }`}
|
|
|
+ style={{
|
|
|
+ boxShadow: slot.status === '1' || slot.isOccupied === '1' ? '0 0 6px #ff0000' : 'none',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <div className="w-5 h-6 rounded bg-gradient-to-b from-black to-gray-900 border border-gray-700 shadow-inner" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="absolute bottom-3 left-1/2 -translate-x-1/2 text-xs text-gray-600 bg-white/70 backdrop-blur px-3 py-1 rounded-full border border-gray-200 pointer-events-none">
|
|
|
+ 拖拽鼠标旋转 3D 视角 | 滚轮缩放
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
|
|
|
- // 绘制仓位
|
|
|
- rowSlots.forEach((slot, slotIndex) => {
|
|
|
- const { slotType, isOccupied, status } = slot;
|
|
|
- let baseKey = '';
|
|
|
-
|
|
|
- if (slotType === '0') {
|
|
|
- baseKey = isOccupied === '1' ? 'icon.locker.normal' : 'icon.locker.out';
|
|
|
- } else {
|
|
|
- baseKey = isOccupied === '1' ? 'icon.padlock.normal' : 'icon.padlock.out';
|
|
|
- }
|
|
|
-
|
|
|
- const baseUrl = cachedResults[baseKey];
|
|
|
- const baseImg = baseUrl ? cachedImages[baseUrl] : null;
|
|
|
-
|
|
|
- const width = slotType === '0' ? 110 : 40;
|
|
|
- const height = 90;
|
|
|
- const padding = 20;
|
|
|
- const totalSlots = rowSlots.length;
|
|
|
- const spacing = totalSlots > 1
|
|
|
- ? (boxWidth - 2 * padding - totalSlots * width) / (totalSlots - 1)
|
|
|
- : 0;
|
|
|
-
|
|
|
- const x = boxStartX + padding + slotIndex * (width + spacing);
|
|
|
- const y = startY + rowIndex * (rowHeight + rowGap) + (rowHeight - height) / 2;
|
|
|
-
|
|
|
- // 绘制仓位图标
|
|
|
- if (baseImg) {
|
|
|
- ctx.drawImage(baseImg, x, y, width, height);
|
|
|
- } else {
|
|
|
- // 如果没有图片,绘制占位矩形
|
|
|
- ctx.fillStyle = '#f0f0f0';
|
|
|
- ctx.fillRect(x, y, width, height);
|
|
|
- ctx.strokeStyle = '#ccc';
|
|
|
- ctx.lineWidth = 1;
|
|
|
- ctx.strokeRect(x, y, width, height);
|
|
|
- }
|
|
|
-
|
|
|
- // 绘制异常图标
|
|
|
- if (status === '1') {
|
|
|
- const exUrl = cachedResults['icon.locker.exception'];
|
|
|
- const exImg = exUrl ? cachedImages[exUrl] : null;
|
|
|
- if (exImg) {
|
|
|
- const exWidth = 30;
|
|
|
- const exHeight = 30;
|
|
|
- ctx.drawImage(
|
|
|
- exImg,
|
|
|
- x + (width - exWidth) / 2,
|
|
|
- y + (height - exHeight) / 2,
|
|
|
- exWidth,
|
|
|
- exHeight
|
|
|
- );
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 添加点击事件处理
|
|
|
- canvas.addEventListener('click', (e) => {
|
|
|
- const rect = canvas.getBoundingClientRect();
|
|
|
- const clickX = e.clientX - rect.left;
|
|
|
- const clickY = e.clientY - rect.top;
|
|
|
-
|
|
|
- if (clickX >= x && clickX <= x + width && clickY >= y && clickY <= y + height) {
|
|
|
- if (status === '1') {
|
|
|
- setErrorInfo(slot.remark || '未知异常');
|
|
|
- setDialogVisible(true);
|
|
|
- } else {
|
|
|
- console.log('点击仓位:', slot);
|
|
|
- }
|
|
|
- }
|
|
|
- });
|
|
|
- });
|
|
|
- });
|
|
|
- };
|
|
|
+ {/* 右侧:详情信息 */}
|
|
|
+ <div className="w-[420px] h-full min-h-0 bg-white rounded-lg shadow-sm p-5 overflow-hidden flex flex-col">
|
|
|
+ {/* 顶部信息(固定不滚动) */}
|
|
|
+ <div className="shrink-0">
|
|
|
+ {/* 标题 */}
|
|
|
+ <div className="mb-4">
|
|
|
+ <h2 className="text-lg font-bold text-gray-900 mb-2">锁控柜信息</h2>
|
|
|
+ <div className="h-1 bg-blue-500 rounded" />
|
|
|
+ </div>
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- if (cabinetId) {
|
|
|
- getData();
|
|
|
- }
|
|
|
- }, [cabinetId]);
|
|
|
+ {/* 基本信息 */}
|
|
|
+ <div className="mb-4 space-y-2">
|
|
|
+ <div className="flex justify-between items-center py-2 border-b border-gray-200">
|
|
|
+ <span className="text-gray-600">名称</span>
|
|
|
+ <span className="text-gray-900 font-medium">{cabinetInfo?.cabinetName || '-'}</span>
|
|
|
+ </div>
|
|
|
+ <div className="flex justify-between items-center py-2 border-b border-gray-200">
|
|
|
+ <span className="text-gray-600">硬件ID</span>
|
|
|
+ <span className="text-gray-900 font-medium">{cabinetInfo?.hardwareId || '-'}</span>
|
|
|
+ </div>
|
|
|
+ <div className="flex justify-between items-center py-2 border-b border-gray-200">
|
|
|
+ <span className="text-gray-600">序列号</span>
|
|
|
+ <span className="text-gray-900 font-medium">{cabinetInfo?.serialNumber || '-'}</span>
|
|
|
+ </div>
|
|
|
+ <div className="flex justify-between items-center py-2 border-b border-gray-200">
|
|
|
+ <span className="text-gray-600">岗位</span>
|
|
|
+ <span className="text-gray-900 font-medium">{cabinetInfo?.workstationName || '-'}</span>
|
|
|
+ </div>
|
|
|
+ <div className="flex justify-between items-center py-2 border-b border-gray-200">
|
|
|
+ <span className="text-gray-600">在线状态</span>
|
|
|
+ <span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
|
|
+ cabinetInfo?.isOnline === '1'
|
|
|
+ ? 'bg-green-100 text-green-700'
|
|
|
+ : 'bg-gray-100 text-gray-700'
|
|
|
+ }`}>
|
|
|
+ {cabinetInfo?.isOnline === '1' ? '在线' : '离线'}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div className="flex justify-between items-center py-2 border-b border-gray-200">
|
|
|
+ <span className="text-gray-600">运行状态</span>
|
|
|
+ <span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
|
|
+ cabinetInfo?.status === '0'
|
|
|
+ ? 'bg-blue-100 text-blue-700'
|
|
|
+ : cabinetInfo?.status === '1'
|
|
|
+ ? 'bg-red-100 text-red-700'
|
|
|
+ : 'bg-gray-100 text-gray-700'
|
|
|
+ }`}>
|
|
|
+ {cabinetInfo?.status === '0' ? '正常' : cabinetInfo?.status === '1' ? '异常' : '未知'}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- if (slotData.length > 0 && Object.keys(cachedImages).length > 0) {
|
|
|
- drawCanvas();
|
|
|
- }
|
|
|
- }, [slotData, cachedImages, cachedResults]);
|
|
|
+ {/* 统计卡片:左侧(总槽位/空闲),右侧两个盒子(上:已占用,下:异常,随机数) */}
|
|
|
+ <div className="grid grid-cols-2 gap-3 mb-3">
|
|
|
+ {/* 左列 */}
|
|
|
+ <div className="flex flex-col gap-3">
|
|
|
+ <div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl p-3 text-white shadow-lg">
|
|
|
+ <div className="text-2xl font-bold leading-none mb-1">{stats.total}</div>
|
|
|
+ <div className="text-xs opacity-90">总槽位</div>
|
|
|
+ </div>
|
|
|
+ <div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl p-3 text-white shadow-lg">
|
|
|
+ <div className="text-2xl font-bold leading-none mb-1">{stats.free}</div>
|
|
|
+ <div className="text-xs opacity-90">空闲</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 右列(随机数) */}
|
|
|
+ <div className="flex flex-col gap-3">
|
|
|
+ <div
|
|
|
+ className="rounded-xl p-3 text-white shadow-lg"
|
|
|
+ style={{ background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)' }}
|
|
|
+ >
|
|
|
+ <div className="text-2xl font-bold leading-none mb-1">{randomRightStats.occupied}</div>
|
|
|
+ <div className="text-xs opacity-90">已占用</div>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ className="rounded-xl p-3 text-white shadow-lg"
|
|
|
+ style={{ background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' }}
|
|
|
+ >
|
|
|
+ <div className="text-2xl font-bold leading-none mb-1">{randomRightStats.abnormal}</div>
|
|
|
+ <div className="text-xs opacity-90">异常</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
|
|
|
- return (
|
|
|
- <div className="space-y-4">
|
|
|
- {/* 时间卡片 */}
|
|
|
- {updateTime && (
|
|
|
- <div className="inline-flex items-center gap-3 px-5 py-3 bg-gradient-to-r from-gray-50 to-gray-100 rounded-lg shadow-sm">
|
|
|
- <Clock className="w-5 h-5 text-blue-500" />
|
|
|
+ {/* 列表区域(超出才滚动) */}
|
|
|
+ <div className="flex-1 min-h-0 overflow-y-auto pr-1">
|
|
|
+ {/* 设备列表 */}
|
|
|
<div>
|
|
|
- <div className="text-xs text-gray-500">最后更新</div>
|
|
|
- <div className="text-base font-bold text-gray-800">{updateTime}</div>
|
|
|
+ <h3 className="text-base font-bold text-gray-900 mb-3">设备列表</h3>
|
|
|
+ <div className="space-y-3">
|
|
|
+ {slotData.slice(0, 10).map((slot, idx) => (
|
|
|
+ <div
|
|
|
+ key={idx}
|
|
|
+ className="bg-gray-50 rounded-lg p-3 border border-gray-200 hover:shadow-md transition-shadow"
|
|
|
+ >
|
|
|
+ <div className="flex items-center justify-between mb-2">
|
|
|
+ <span className="font-medium text-gray-900">
|
|
|
+ {getSlotTypeName(slot)}-{slot.slotName || slot.slotCode || idx + 1}
|
|
|
+ </span>
|
|
|
+ <span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
|
+ slot.status === '1'
|
|
|
+ ? 'bg-red-100 text-red-700'
|
|
|
+ : slot.isOccupied === '1'
|
|
|
+ ? 'bg-green-100 text-green-700'
|
|
|
+ : 'bg-gray-100 text-gray-700'
|
|
|
+ }`}>
|
|
|
+ {getSlotStatus(slot)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div className="text-sm text-gray-600">
|
|
|
+ {getSlotTypeName(slot)} · 槽位 {slot.slotName || slot.slotCode || idx + 1} ·
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
+
|
|
|
+ {/* 更新时间 */}
|
|
|
+ {updateTime && (
|
|
|
+ <div className="mt-4 inline-flex items-center gap-3 px-4 py-2 bg-gradient-to-r from-gray-50 to-gray-100 rounded-lg">
|
|
|
+ <Clock className="w-4 h-4 text-blue-500" />
|
|
|
+ <div>
|
|
|
+ <div className="text-xs text-gray-500">最后更新</div>
|
|
|
+ <div className="text-sm font-bold text-gray-800">{updateTime}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* 画布容器 */}
|
|
|
- <div className="bg-white rounded-lg shadow-sm p-4 overflow-auto">
|
|
|
- <canvas
|
|
|
- ref={canvasRef}
|
|
|
- width={900}
|
|
|
- height={1000}
|
|
|
- className="border border-gray-200"
|
|
|
- />
|
|
|
</div>
|
|
|
|
|
|
{/* 异常信息对话框 */}
|
|
|
@@ -264,4 +676,3 @@ export default function MapData({ cabinetId }: MapDataProps) {
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
-
|