| | |
| | | |
| | | <!-- 工具栏 --> |
| | | <div class="toolbar"> |
| | | <el-select v-model="filterStockStatus" placeholder="库存状态筛选" clearable> |
| | | <el-option label="有货" :value="1" /> |
| | | <el-option label="库存紧张" :value="2" /> |
| | | <el-option label="已满" :value="3" /> |
| | | <el-select v-model="filterStockStatus" placeholder="库存状态筛选" style="width: 160px" clearable> |
| | | <el-option label="组盘暂存(1)" :value="1" /> |
| | | <el-option label="入库确认(3)" :value="3" /> |
| | | <el-option label="入库完成(6)" :value="6" /> |
| | | <el-option label="出库锁定(7)" :value="7" /> |
| | | <el-option label="出库完成(8)" :value="8" /> |
| | | <el-option label="空托盘(22)" :value="22" /> |
| | | </el-select> |
| | | <el-select v-model="filterMaterielCode" placeholder="物料筛选" clearable> |
| | | <el-select v-model="filterMaterielCode" placeholder="物料筛选" style="width: 140px" clearable> |
| | | <el-option v-for="code in materielCodeList" :key="code" :label="code" :value="code" /> |
| | | </el-select> |
| | | <el-select v-model="filterBatchNo" placeholder="批次筛选" clearable> |
| | | <el-select v-model="filterBatchNo" placeholder="批次筛选" style="width: 140px" clearable> |
| | | <el-option v-for="batch in batchNoList" :key="batch" :label="batch" :value="batch" /> |
| | | </el-select> |
| | | <el-button @click="resetCamera">重置视角</el-button> |
| | | <el-button type="primary" @click="refreshData" :loading="refreshing">刷新数据</el-button> |
| | | </div> |
| | | |
| | | <!-- 3D Canvas --> |
| | |
| | | |
| | | <!-- 状态图例 --> |
| | | <div class="legend"> |
| | | <div class="legend-title">货位状态</div> |
| | | <div v-for="item in legendItems" :key="item.status" class="legend-item"> |
| | | <span class="color-box" :style="{ background: item.color }" /> |
| | | <span>{{ item.label }}</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 详情弹窗 --> |
| | | <el-dialog v-model="detailDialogVisible" title="库存详情" fullscreen> |
| | | <!-- 详情侧边面板 --> |
| | | <el-drawer v-model="detailDialogVisible" title="库存详情" direction="rtl" size="500px"> |
| | | <div v-if="selectedLocation" class="detail-content"> |
| | | <el-descriptions :column="2" border> |
| | | <el-descriptions-item label="货位编号">{{ selectedLocation.locationCode }}</el-descriptions-item> |
| | | <el-descriptions-item label="货位状态">{{ getLocationStatusText(selectedLocation.locationStatus) }}</el-descriptions-item> |
| | | <el-descriptions-item label="托盘编号">{{ selectedLocation.palletCode || '无' }}</el-descriptions-item> |
| | | <el-descriptions-item label="库存状态">{{ getStockStatusText(selectedLocation.stockStatus) }}</el-descriptions-item> |
| | | <el-descriptions-item label="当前库存">{{ selectedLocation.stockQuantity }}</el-descriptions-item> |
| | | <el-descriptions-item label="物料编号">{{ selectedLocation.materielCode || '无' }}</el-descriptions-item> |
| | | <el-descriptions-item label="物料名称">{{ selectedLocation.materielName || '无' }}</el-descriptions-item> |
| | | <el-descriptions-item label="批次号">{{ selectedLocation.batchNo || '无' }}</el-descriptions-item> |
| | | <el-descriptions-item label="总库存">{{ selectedLocation.stockQuantity }}{{ selectedLocation.unit || '' }}</el-descriptions-item> |
| | | </el-descriptions> |
| | | |
| | | <!-- 库存明细表格 --> |
| | | <div v-if="selectedLocation.details && selectedLocation.details.length > 0" class="detail-table"> |
| | | <h4>库存明细</h4> |
| | | <el-table :data="selectedLocation.details" border size="small" max-height="400"> |
| | | <el-table-column prop="materielCode" label="物料编码" width="100" /> |
| | | <el-table-column prop="materielName" label="物料名称" min-width="120" show-overflow-tooltip /> |
| | | <el-table-column prop="batchNo" label="批次号" width="100" show-overflow-tooltip /> |
| | | <el-table-column prop="stockQuantity" label="数量" width="70" align="right" /> |
| | | <el-table-column prop="unit" label="单位" width="50" align="center" /> |
| | | <el-table-column prop="effectiveDate" label="有效期" width="100" /> |
| | | </el-table> |
| | | </div> |
| | | <div v-else class="no-detail"> |
| | | <el-empty description="暂无库存明细" /> |
| | | </div> |
| | | </div> |
| | | </el-dialog> |
| | | </el-drawer> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, reactive, onMounted, onUnmounted, watch, getCurrentInstance } from 'vue' |
| | | import { ref, reactive, onMounted, onUnmounted, watch, nextTick, getCurrentInstance } from 'vue' |
| | | import * as THREE from 'three' |
| | | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' |
| | | import * as signalR from '@microsoft/signalr' |
| | |
| | | // SignalR 连接 |
| | | let connection = null |
| | | |
| | | // 颜色常量 |
| | | // 颜色常量 - 基于实际枚举值 |
| | | // 货位状态(locationStatus): 0=空闲, 1=锁定, 10=有货锁定, 20=空闲锁定, 99=大托盘锁定, 100=有货 |
| | | // 库存状态(stockStatus): 1=组盘暂存, 3=入库确认, 6=入库完成, 7=出库锁定, 8=出库完成, 9=移库锁定等 |
| | | const COLOR_MAP = { |
| | | DISABLED: 0x2d2d2d, // 禁用 - 深灰 |
| | | LOCKED: 0xF56C6C, // 锁定 - 红色 |
| | | EMPTY: 0x4a4a4a, // 空货位 - 暗灰 |
| | | HAS_STOCK: 0x409EFF, // 有货 - 蓝色 |
| | | LOW_STOCK: 0xE6A23C, // 库存紧张 - 橙色 |
| | | FULL: 0x67C23A, // 已满 - 绿色 |
| | | // 货位状态颜色 |
| | | LOC_FREE: 0x90EE90, // 0=空闲 - 浅绿色 |
| | | LOC_LOCK: 0xFF6B6B, // 1=锁定 - 红色 |
| | | LOC_INSTOCK_LOCK: 0xFFA500, // 10=有货锁定 - 橙色 |
| | | LOC_FREE_LOCK: 0xFFD700, // 20=空闲锁定 - 金色 |
| | | LOC_PALLET_LOCK: 0x9370DB, // 99=大托盘锁定 - 紫色 |
| | | LOC_INSTOCK: 0x409EFF, // 100=有货 - 蓝色 |
| | | |
| | | // 库存状态颜色 |
| | | STOCK_PENDING: 0x00CED1, // 1=组盘暂存 - 深青色 |
| | | STOCK_CONFIRMED: 0x87CEEB, // 3=入库确认 - 天蓝色 |
| | | STOCK_COMPLETED: 0x32CD32, // 6=入库完成 - 亮绿色 |
| | | STOCK_OUT_LOCK: 0xFF6347, // 7=出库锁定 - 番茄红 |
| | | STOCK_OUT_COMPLETED: 0x228B22, // 8=出库完成 - 森林绿 |
| | | STOCK_TRANSFER_LOCK: 0xFF8C00, // 9=移库锁定 - 深橙色 |
| | | STOCK_COMPLETED_NO_ORDER: 0x20B2AA, // 10=入库完成未建出库单 |
| | | STOCK_RETURN: 0xFF4500, // 11=退库 |
| | | STOCK_MANUAL_PENDING: 0x48D1CC, // 12=手动组盘暂存 |
| | | STOCK_MANUAL_CONFIRMED: 0x7FFFD4, // 13=手动组盘入库确认 |
| | | STOCK_PICK_COMPLETED: 0x6B8E23, // 14=拣选完成 |
| | | STOCK_MES_RETURN: 0xDC143C, // 21=MES退库 |
| | | STOCK_EMPTY_TRAY: 0xDDA0DD, // 22=空托盘库存 - 梅红色 |
| | | STOCK_GROUP_CANCEL: 0xDEB887, // 99=组盘撤销 - 暗金色 |
| | | STOCK_IN_CANCEL: 0xA0522D, // 199=入库撤销 - 赭色 |
| | | } |
| | | |
| | | // 图例项 |
| | | // 图例项 - 货位状态 |
| | | const legendItems = [ |
| | | { status: 'disabled', label: '禁用', color: '#2d2d2d' }, |
| | | { status: 'locked', label: '锁定', color: '#F56C6C' }, |
| | | { status: 'empty', label: '空货位', color: '#4a4a4a' }, |
| | | { status: 'hasStock', label: '有货', color: '#409EFF' }, |
| | | { status: 'lowStock', label: '库存紧张', color: '#E6A23C' }, |
| | | { status: 'full', label: '已满', color: '#67C23A' }, |
| | | { status: 'loc_free', label: '空闲(0)', color: '#90EE90' }, |
| | | { status: 'loc_lock', label: '锁定(1)', color: '#FF6B6B' }, |
| | | { status: 'loc_instock_lock', label: '有货锁定(10)', color: '#FFA500' }, |
| | | { status: 'loc_free_lock', label: '空闲锁定(20)', color: '#FFD700' }, |
| | | { status: 'loc_pallet_lock', label: '大托盘锁定(99)', color: '#9370DB' }, |
| | | { status: 'loc_instock', label: '有货(100)', color: '#409EFF' }, |
| | | ] |
| | | |
| | | // Refs |
| | |
| | | const batchNoList = ref([]) |
| | | const detailDialogVisible = ref(false) |
| | | const selectedLocation = ref(null) |
| | | const refreshing = ref(false) |
| | | |
| | | // Three.js 相关 |
| | | let scene, camera, renderer, controls, raycaster, mouse |
| | | let locationMesh = null |
| | | let locationData = [] |
| | | let originalLocationData = [] // 保存原始完整数据,用于筛选恢复 |
| | | let animationId = null |
| | | let locationIdToInstanceId = new Map() // locationId -> instanceId 映射 |
| | | |
| | | // SignalR 初始化 |
| | | function initSignalR() { |
| | |
| | | connection.start().catch((err) => console.log('SignalR连接失败:', err)); |
| | | |
| | | connection.on('StockUpdated', (update) => { |
| | | console.log('收到库存更新:', update) |
| | | // 更新对应货位的数据 |
| | | const idx = locationData.findIndex(x => x.locationId === update.locationId); |
| | | if (idx !== -1) { |
| | | locationData[idx].stockQuantity = update.stockQuantity; |
| | | locationData[idx].stockStatus = update.stockStatus; |
| | | // 重新渲染单个货位颜色 |
| | | updateInstanceColor(idx, update.stockStatus); |
| | | locationData[idx].palletCode = update.palletCode; |
| | | locationData[idx].locationStatus = update.locationStatus; |
| | | // 更新库存明细 |
| | | if (update.details && update.details.length > 0) { |
| | | locationData[idx].details = update.details; |
| | | } |
| | | // 通过映射找到实例ID,更新颜色 |
| | | const instanceId = locationIdToInstanceId.get(update.locationId); |
| | | if (instanceId !== undefined) { |
| | | updateInstanceColor(instanceId, update.locationStatus); |
| | | } |
| | | } |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | // 更新单个货位颜色 |
| | | function updateInstanceColor(instanceId, stockStatus) { |
| | | function updateInstanceColor(instanceId, locationStatus) { |
| | | if (!locationMesh) return; |
| | | const loc = locationData[instanceId]; |
| | | if (!loc) return; |
| | | const color = getLocationColor(loc); |
| | | // 根据货位状态获取颜色 |
| | | const color = getLocationColorByStatus(locationStatus); |
| | | locationMesh.setColorAt(instanceId, new THREE.Color(color)); |
| | | locationMesh.instanceColor.needsUpdate = true; |
| | | } |
| | | |
| | | // 获取货位颜色 |
| | | function getLocationColor(location) { |
| | | if (location.locationStatus === 3) return COLOR_MAP.DISABLED // 禁用 |
| | | if (location.locationStatus === 2) return COLOR_MAP.LOCKED // 锁定 |
| | | if (location.locationStatus === 1) { |
| | | if (location.stockStatus === 0) return COLOR_MAP.EMPTY // 无货 |
| | | if (location.stockStatus === 1) return COLOR_MAP.HAS_STOCK // 有货 |
| | | if (location.stockStatus === 2) return COLOR_MAP.LOW_STOCK // 库存紧张 |
| | | if (location.stockStatus === 3) return COLOR_MAP.FULL // 已满 |
| | | // 根据货位状态获取颜色 |
| | | function getLocationColorByStatus(locStatus) { |
| | | switch (locStatus) { |
| | | case 0: return COLOR_MAP.LOC_FREE // 空闲 |
| | | case 1: return COLOR_MAP.LOC_LOCK // 锁定 |
| | | case 10: return COLOR_MAP.LOC_INSTOCK_LOCK // 有货锁定 |
| | | case 20: return COLOR_MAP.LOC_FREE_LOCK // 空闲锁定 |
| | | case 99: return COLOR_MAP.LOC_PALLET_LOCK // 大托盘锁定 |
| | | case 100: return COLOR_MAP.LOC_INSTOCK // 有货 |
| | | default: return COLOR_MAP.LOC_FREE // 默认空闲色 |
| | | } |
| | | return COLOR_MAP.EMPTY // 默认空 |
| | | } |
| | | |
| | | // 获取货位颜色 - 只根据货位状态 |
| | | function getLocationColor(location) { |
| | | const locStatus = location.locationStatus |
| | | |
| | | // 根据货位状态判断颜色 |
| | | switch (locStatus) { |
| | | case 0: return COLOR_MAP.LOC_FREE // 空闲 |
| | | case 1: return COLOR_MAP.LOC_LOCK // 锁定 |
| | | case 10: return COLOR_MAP.LOC_INSTOCK_LOCK // 有货锁定 |
| | | case 20: return COLOR_MAP.LOC_FREE_LOCK // 空闲锁定 |
| | | case 99: return COLOR_MAP.LOC_PALLET_LOCK // 大托盘锁定 |
| | | case 100: return COLOR_MAP.LOC_INSTOCK // 有货 |
| | | default: return COLOR_MAP.LOC_FREE // 默认空闲色 |
| | | } |
| | | } |
| | | |
| | | // 获取货位状态文本 |
| | | function getLocationStatusText(status) { |
| | | const map = { 0: '正常', 1: '正常', 2: '锁定', 3: '禁用' } |
| | | return map[status] || '未知' |
| | | const map = { |
| | | 0: '空闲', |
| | | 1: '锁定', |
| | | 10: '有货锁定', |
| | | 20: '空闲锁定', |
| | | 99: '大托盘锁定', |
| | | 100: '有货' |
| | | } |
| | | return map[status] || '未知(' + status + ')' |
| | | } |
| | | |
| | | // 获取库存状态文本 |
| | | function getStockStatusText(status) { |
| | | const map = { 0: '无货', 1: '有货', 2: '库存紧张', 3: '已满' } |
| | | return map[status] || '未知' |
| | | const map = { |
| | | 0: '无库存', |
| | | 1: '组盘暂存', |
| | | 3: '入库确认', |
| | | 6: '入库完成', |
| | | 7: '出库锁定', |
| | | 8: '出库完成', |
| | | 9: '移库锁定', |
| | | 10: '入库完成未建出库单', |
| | | 11: '退库', |
| | | 12: '手动组盘暂存', |
| | | 13: '手动组盘入库确认', |
| | | 14: '拣选完成', |
| | | 21: 'MES退库', |
| | | 22: '空托盘库存', |
| | | 99: '组盘撤销', |
| | | 199: '入库撤销' |
| | | } |
| | | return map[status] || '未知(' + status + ')' |
| | | } |
| | | |
| | | // 加载仓库列表 |
| | |
| | | async function loadWarehouseData(warehouseId) { |
| | | try { |
| | | const res = await proxy.http.get(`/api/StockInfo/Get3DLayout?warehouseId=${warehouseId}`) |
| | | console.log('Get3DLayout response:', res) |
| | | if (res.status && res.data) { |
| | | const data = res.data |
| | | console.log('data.locations:', data.locations) |
| | | locationData = data.locations || [] |
| | | originalLocationData = data.locations || [] // 保存原始完整数据 |
| | | locationData = [...originalLocationData] // 当前显示数据 |
| | | // 使用后端返回的筛选列表 |
| | | materielCodeList.value = data.materielCodeList || [] |
| | | batchNoList.value = data.batchNoList || [] |
| | | console.log('locationData set:', locationData.length, 'items') |
| | | // 渲染货位 |
| | | renderLocations() |
| | | } |
| | |
| | | // 创建相机 |
| | | camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000) |
| | | camera.position.set(20, 20, 20) |
| | | camera.lookAt(0, 0, 0) |
| | | |
| | | // 创建渲染器 |
| | | renderer = new THREE.WebGLRenderer({ antialias: true }) |
| | |
| | | controls.dampingFactor = 0.05 |
| | | |
| | | // 创建环境光 |
| | | const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) |
| | | const ambientLight = new THREE.AmbientLight(0xffffff, 0.5) |
| | | scene.add(ambientLight) |
| | | |
| | | // 创建定向光 |
| | | const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8) |
| | | directionalLight.position.set(10, 20, 10) |
| | | // 创建主定向光 |
| | | const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0) |
| | | directionalLight.position.set(50, 100, 50) |
| | | scene.add(directionalLight) |
| | | |
| | | // 创建补光 |
| | | const fillLight = new THREE.DirectionalLight(0xffffff, 0.3) |
| | | fillLight.position.set(-50, 50, -50) |
| | | scene.add(fillLight) |
| | | |
| | | // 创建地面 |
| | | const groundGeometry = new THREE.PlaneGeometry(100, 100) |
| | |
| | | mouse = new THREE.Vector2() |
| | | |
| | | // 添加点击事件 |
| | | canvasContainer.value.addEventListener('click', onCanvasClick) |
| | | canvasContainer.value.addEventListener('mousedown', onCanvasClick) |
| | | |
| | | // 开始渲染循环 |
| | | animate() |
| | |
| | | |
| | | // 渲染货位 |
| | | function renderLocations() { |
| | | console.log('renderLocations called', { scene: !!scene, locationDataLength: locationData.length }) |
| | | if (!scene) return |
| | | console.log('渲染货位,原始数据总数:', originalLocationData.length) |
| | | // 如果原始数据为空,尝试重新加载 |
| | | if (originalLocationData.length === 0) { |
| | | console.warn('原始数据为空,重新加载...') |
| | | if (activeWarehouse.value) { |
| | | loadWarehouseData(activeWarehouse.value) |
| | | } |
| | | return |
| | | } |
| | | |
| | | // 移除旧的货位网格 |
| | | console.log("🚀 ~ renderLocations ~ locationMesh:", locationMesh) |
| | | if (locationMesh) { |
| | | scene.remove(locationMesh) |
| | | locationMesh.geometry.dispose() |
| | | locationMesh.material.forEach(m => m.dispose()) |
| | | if (Array.isArray(locationMesh.material)) { |
| | | locationMesh.material.forEach(m => m.dispose()) |
| | | } else { |
| | | locationMesh.material.dispose() |
| | | } |
| | | locationMesh = null |
| | | } |
| | | |
| | | // 过滤数据 |
| | | let filteredData = [...locationData] |
| | | // 过滤数据 - 始终从原始完整数据过滤 |
| | | let filteredData = [...originalLocationData] |
| | | if (filterStockStatus.value !== null) { |
| | | filteredData = filteredData.filter(loc => loc.stockStatus === filterStockStatus.value) |
| | | } |
| | |
| | | filteredData = filteredData.filter(loc => loc.batchNo === filterBatchNo.value) |
| | | } |
| | | |
| | | console.log('filteredData length:', filteredData.length) |
| | | if (filteredData.length === 0) return |
| | | console.log('过滤后数据数量:', filteredData.length, '筛选条件:', filterStockStatus.value, filterMaterielCode.value, filterBatchNo.value) |
| | | |
| | | // 创建 InstancedMesh |
| | | const geometry = new THREE.BoxGeometry(1.5, 1, 1.5) |
| | | const material = new THREE.MeshStandardMaterial({ color: 0xffffff }) |
| | | const material = new THREE.MeshStandardMaterial({ |
| | | color: 0xffffff, |
| | | roughness: 0.5, |
| | | metalness: 0.1 |
| | | }) |
| | | |
| | | locationMesh = new THREE.InstancedMesh(geometry, [material], filteredData.length) |
| | | // 如果过滤后无数据,创建空的 InstancedMesh |
| | | if (filteredData.length === 0) { |
| | | locationMesh = new THREE.InstancedMesh(geometry, material, 0) |
| | | scene.add(locationMesh) |
| | | return |
| | | } |
| | | |
| | | locationMesh = new THREE.InstancedMesh(geometry, material, filteredData.length) |
| | | |
| | | // 清空并重建映射 |
| | | locationIdToInstanceId.clear() |
| | | |
| | | const dummy = new THREE.Object3D() |
| | | const color = new THREE.Color() |
| | |
| | | color.setHex(getLocationColor(location)) |
| | | locationMesh.setColorAt(i, color) |
| | | |
| | | // 建立映射: locationId -> instanceId (i) |
| | | locationIdToInstanceId.set(location.locationId, i) |
| | | |
| | | if (i === 0) { |
| | | console.log('First location:', location, { x, y, z }) |
| | | } |
| | |
| | | if (locationMesh.instanceColor) locationMesh.instanceColor.needsUpdate = true |
| | | |
| | | scene.add(locationMesh) |
| | | console.log('locationMesh added to scene') |
| | | } |
| | | |
| | | // 动画循环 |
| | |
| | | if (intersects.length > 0) { |
| | | const instanceId = intersects[0].instanceId |
| | | // 获取原始数据索引(考虑过滤后的数据) |
| | | let filteredData = locationData |
| | | let filteredData = [...originalLocationData] |
| | | if (filterStockStatus.value !== null) { |
| | | filteredData = filteredData.filter(loc => loc.stockStatus === filterStockStatus.value) |
| | | } |
| | |
| | | controls.target.set(0, 0, 0) |
| | | } |
| | | |
| | | // 刷新数据 |
| | | async function refreshData() { |
| | | refreshing.value = true |
| | | try { |
| | | // 重置筛选条件 |
| | | filterStockStatus.value = null |
| | | filterMaterielCode.value = null |
| | | filterBatchNo.value = null |
| | | // 重新加载当前仓库数据 |
| | | if (activeWarehouse.value) { |
| | | await loadWarehouseData(activeWarehouse.value) |
| | | } |
| | | } finally { |
| | | refreshing.value = false |
| | | } |
| | | } |
| | | |
| | | // 仓库切换 |
| | | async function onWarehouseChange(warehouseId) { |
| | | await loadWarehouseData(warehouseId) |
| | | } |
| | | |
| | | // 监听筛选变化 |
| | | watch(filterStockStatus, () => renderLocations()) |
| | | watch(filterMaterielCode, () => renderLocations()) |
| | | watch(filterBatchNo, () => renderLocations()) |
| | | watch(filterStockStatus, async () => { |
| | | await nextTick() |
| | | if (originalLocationData.length > 0) { |
| | | renderLocations() |
| | | } |
| | | }) |
| | | watch(filterMaterielCode, async () => { |
| | | await nextTick() |
| | | if (originalLocationData.length > 0) { |
| | | renderLocations() |
| | | } |
| | | }) |
| | | watch(filterBatchNo, async () => { |
| | | await nextTick() |
| | | if (originalLocationData.length > 0) { |
| | | renderLocations() |
| | | } |
| | | }) |
| | | |
| | | // 窗口大小变化 |
| | | function onWindowResize() { |
| | |
| | | cancelAnimationFrame(animationId) |
| | | } |
| | | if (canvasContainer.value) { |
| | | canvasContainer.value.removeEventListener('click', onCanvasClick) |
| | | canvasContainer.value.removeEventListener('mousedown', onCanvasClick) |
| | | } |
| | | window.removeEventListener('resize', onWindowResize) |
| | | if (renderer) { |
| | |
| | | width: 100%; |
| | | height: calc(100vh - 120px); |
| | | position: relative; |
| | | overflow: visible; |
| | | } |
| | | .toolbar { |
| | | position: absolute; |
| | |
| | | background: rgba(255, 255, 255, 0.9); |
| | | padding: 10px; |
| | | border-radius: 4px; |
| | | box-shadow: 0 2px 8px rgba(0,0,0,0.15); |
| | | } |
| | | .canvas-container { |
| | | width: 100%; |
| | | height: 100%; |
| | | overflow: hidden; |
| | | } |
| | | .legend { |
| | | position: absolute; |
| | |
| | | border-radius: 4px; |
| | | color: white; |
| | | z-index: 10; |
| | | max-height: 400px; |
| | | overflow-y: auto; |
| | | } |
| | | .legend-title { |
| | | font-weight: bold; |
| | | font-size: 13px; |
| | | margin-bottom: 6px; |
| | | color: #fff; |
| | | } |
| | | .legend-divider { |
| | | height: 1px; |
| | | background: rgba(255, 255, 255, 0.3); |
| | | margin: 8px 0; |
| | | } |
| | | .legend-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | margin-bottom: 4px; |
| | | font-size: 12px; |
| | | } |
| | | .color-box { |
| | | width: 16px; |
| | | height: 16px; |
| | | border-radius: 2px; |
| | | flex-shrink: 0; |
| | | } |
| | | .detail-content { |
| | | padding: 20px; |
| | | } |
| | | .detail-table { |
| | | margin-top: 20px; |
| | | } |
| | | .detail-table h4 { |
| | | margin-bottom: 10px; |
| | | color: #303133; |
| | | } |
| | | .no-detail { |
| | | margin-top: 20px; |
| | | } |
| | | |
| | | :deep(.el-drawer__body) { |
| | | padding: 20px; |
| | | } |
| | | </style> |