| .gitignore | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| .oxfmtrc.json | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/webapp/components/MapCanvas.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/webapp/components/MapCanvasBak.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/webapp/static/js/basMap/editor.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/webapp/static/js/basMap/mapTrackGeometry.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/webapp/views/basMap/editor.html | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/webapp/views/watch/console.html | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/webapp/views/watch/fakeTrace.html | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/webapp/views/watch/stationTrace.html | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
.gitignore
@@ -34,7 +34,9 @@ ### VS Code ### .vscode/ .history/ .cursor/ ### LOG ### stock LOG_PATH_IS_UNDEFINED LOG_PATH_IS_UNDEFINED .oxfmtrc.json
New file @@ -0,0 +1,4 @@ { "singleQuote": true, "trailingComma": "none" } src/main/webapp/components/MapCanvas.js
Diff too large src/main/webapp/components/MapCanvasBak.js
New file @@ -0,0 +1,258 @@ //MapCanvas.js 里废弃的函数,暂时保留在这里,方便后续如有需要再恢复 // function getMappingInfo(point) { // let angle, path, vector, x, y; // this.allSmoothList.find((smoothList) => { // const smoothLength = smoothList.length; // const existPath = smoothList.find((currentPath, i) => { // const prevPath = smoothList[(i - 1 + smoothLength) % smoothLength]; // if (currentPath.type === "line") { // const prevPathEndPoint = // prevPath.type === "arc" // ? { // x: prevPath.arcEndX, // y: prevPath.arcEndY, // } // : prevPath; // return this.isPointOnSegment(point, prevPathEndPoint, currentPath); // } else { // const start = { // x: currentPath.arcStartX, // y: currentPath.arcStartY, // }; // const middle = { // x: currentPath.arcMiddleX, // y: currentPath.arcMiddleY, // }; // const end = { x: currentPath.arcEndX, y: currentPath.arcEndY }; // return ( // this.isPointOnSegment(point, start, middle) || // this.isPointOnSegment(point, middle, end) // ); // } // }); // if (!existPath) { // return false; // } // path = existPath; // vector = this.normalizeVector(existPath, point); // if (existPath.type === "arc") { // x = existPath.x + vector.x * existPath.radius; // y = existPath.y + vector.y * existPath.radius; // angle = Math.atan2(vector.y, vector.x); // } else { // x = point.x; // y = point.y; // angle = null; // } // return true; // }); // return { // angle, // path, // x, // y, // vector, // }; // } // setDeviceInfo(type, res) { // const deviceTypeInfo = this.DEVICE_MAP[type]; // let devices = Array.isArray(res) ? res : res && res.code === 200 ? res.data : null; // if (!devices) { // return; // } // for (var i = 0; i < devices.length; i++) { // const device = devices[i]; // const id = parseInt(device[deviceTypeInfo.idName]); // const sprite = deviceTypeInfo.pixiMap.get(id); // if (!sprite) { // continue; // } // // 找到sprite了就更新颜色和文字 // const taskNo = device.taskNo; // if (taskNo != null && taskNo > 0) { // sprite.textObj.text = id + '(' + taskNo + ')'; // } else { // sprite.textObj.text = String(id); // } // this.applyEditorLikeTrackDeviceTextStyle(sprite.textObj); // const status = device[deviceTypeInfo.statusInfo.name]; // const statusColor = deviceTypeInfo.statusInfo.getStatus(status); // deviceTypeInfo.statusInfo.updateTextureColor(sprite, statusColor); // const oldSprite = { // ...sprite, // x: sprite.x, // y: sprite.y, // mappingInfo: { ...sprite.mappingInfo } // }; // // 后端传过来的是左上角的点,这里取中心点 // device.width = sprite.width; // device.height = sprite.height; // const sourcePoint = { // x: device.x + device.width / 2, // y: device.y + device.height / 2 // }; // const mappingInfo = this.getMappingInfo({ ...device, ...sourcePoint }); // sprite.mappingInfo = mappingInfo; // sprite.time = Date.now(); // if (!oldSprite.time) { // sprite.x = sprite.mappingInfo.x; // sprite.y = sprite.mappingInfo.y; // sprite.rotation = sprite.mappingInfo.rotate; // sprite.path = sprite.mappingInfo.path; // sprite.vector = sprite.mappingInfo.vector; // continue; // } // // const curveDistance = this.getCurveDistance( // // oldSprite.mappingInfo,sprite.mappingInfo, // // oldSprite.mappingInfo.path,sprite.mappingInfo.path, // // oldSprite.mappingInfo.angle,sprite.mappingInfo.angle // // ); // const curveDistance = G.calcDistance(oldSprite.mappingInfo, sprite.mappingInfo); // const deltaTime = (sprite.time - oldSprite.time) / 1000 || 0; // if (deltaTime > 0 && deltaTime <= 10 && curveDistance > 0) { // // 保存本次帧间隔,供 ticker 判断数据新鲜度 // sprite.lastDeltaTime = deltaTime; // // 用指数移动平均(α=0.35)代替窗口均值,对速度变化响应更快 // const v = curveDistance / deltaTime; // const alpha = 0.35; // sprite.maV = // typeof sprite.maV === 'number' && isFinite(sprite.maV) // ? sprite.maV * (1 - alpha) + v * alpha // : v; // } // if (curveDistance < EPSILON) { // // 已无剩余路程或新数据与当前目标一致:必须收尾并移除 ticker,否则会一直跑 // this.finishDeviceMotion(sprite); // } else { // sprite.isFinish = false; // if (sprite.ticker) { // // 已有插值回调:mappingInfo 已在上方更新,继续追新目标;勿 return(会中断其它设备) // continue; // } // const Gm = window.BasMapTrackGeometry; // const trackElForMove = // this.map2 && sprite.pathId != null // ? this.map2.find((item) => item && item.id === sprite.pathId) // : null; // const pathListForMove = // trackElForMove && // trackElForMove.pathList && // trackElForMove.pathList.length // ? trackElForMove.pathList // : null; // if (!pathListForMove || !Gm) { // this.finishDeviceMotion(sprite); // continue; // } // const fn = () => { // if (sprite.isFinish) { // return; // } // const restDistance = G.calcDistance(sprite, sprite.mappingInfo); // if (restDistance <= EPSILON) { // this.finishDeviceMotion(sprite); // return; // } // const dtMs = // this.pixiApp && this.pixiApp.ticker && typeof this.pixiApp.ticker.deltaMS === 'number' // ? this.pixiApp.ticker.deltaMS // : 16.667; // const dt = Math.max(0, dtMs) / 1000; // if (dt <= 0) { // return; // } // const baseV = // typeof sprite.maV === 'number' && isFinite(sprite.maV) && sprite.maV > 0 // ? sprite.maV // : Math.max(sprite.width * 2, 20); // // 数据新鲜度判断: // // 服务器帧间隔内有新数据到来 → 全速追目标(不减速,避免下一帧"追赶抖动") // // 数据停止推送 → 线性减速收敛到终点 // const msSinceUpdate = sprite.time ? Date.now() - sprite.time : 0; // const typicalIntervalMs = (sprite.lastDeltaTime || 1) * 1000; // const isStale = msSinceUpdate > typicalIntervalMs * 1.5; // const easing = isStale ? Math.min(1.0, restDistance / Math.max(sprite.width, 1)) : 1.0; // const smoothDistance = Math.min(restDistance, dt * baseV * easing); // const path = sprite.path; // const movePoint = // trackElForMove && // trackElForMove.type === 'annulus' && // Gm.snapToAnnulusOuterPath // ? Gm.snapToAnnulusOuterPath(sprite.x, sprite.y, path) // : sprite; // const angle = Math.atan2(sprite.y - path.y, sprite.x - path.x); // const p = Gm.getPositionAfterMove({ // point: movePoint, // pathList: pathListForMove, // path, // deltaDistance: smoothDistance, // angle // }); // let px = p.x; // let py = p.y; // if ( // trackElForMove && // trackElForMove.type === 'annulus' && // Gm.centerAnnulusBandPoint // ) { // const c = Gm.centerAnnulusBandPoint(trackElForMove, p.x, p.y, p.path); // px = c.x; // py = c.y; // } // sprite.path = p.path; // sprite.x = px; // sprite.y = py; // sprite.rotation = this.getRotate(p, p.path); // const restDistanceAfter = G.calcDistance( // { x: sprite.x, y: sprite.y }, // sprite.mappingInfo // ); // if (restDistanceAfter <= EPSILON || smoothDistance >= restDistance) { // this.finishDeviceMotion(sprite); // return; // } // }; // sprite.ticker = fn; // this.pixiApp.ticker.add(sprite.ticker); // } // } // this.scheduleAdjustLabels(); // }, // const list = [{ // x: 853.92, // y: 457 // }, { // x: 953.92, // y: 457 // }, { // x: 1053.92, // y: 457 // }, { // x: 1153.92, // y: 457 // }, { // x: 1253.92, // y: 457 // }, { // x: 1353.92, // y: 457 // }, { // x: 1453.92, // y: 457 // }, { // x: 1553.92, // y: 457 // }, { // x: 1653.92, // y: 457 // }]; // const raw = { // crnStatus: 'machine-auto', // offset: 1, // crnId: 15, // pathId: 'el_1775197504724' // }; src/main/webapp/static/js/basMap/editor.js
Diff too large src/main/webapp/static/js/basMap/mapTrackGeometry.js
New file @@ -0,0 +1,1195 @@ /** * 环穿 / 平滑轨道几何:直角多边形转圆弧路径、PIXI 绘制、设备朝向等。 * 供 basMap 编辑器与监控 MapCanvas 共用。需在页面中先于 editor.js / MapCanvas.js 引入。 * 设备外观(CRN / RGV)绘制与 MapCanvas 贴图一致,见 drawCrnDeviceGraphics / drawRgvDeviceGraphics。 */ (function (global) { 'use strict'; var TYPE_META = { shelf: { label: '货架', shortLabel: 'SHELF', fill: 0x7d96bf, border: 0x4f6486 }, repairHub: { label: '维修站台', shortLabel: 'HUB', fill: 0x8eb89a, border: 0x4a6b55 }, devp: { label: '输送线', shortLabel: 'DEVP', fill: 0xf0b06f, border: 0xa45f21 }, crn: { label: '堆垛机轨道', shortLabel: 'CRN', fill: 0x68bfd0, border: 0x1d6e81, alpha: 0.12 }, dualCrn: { label: '双工位轨道', shortLabel: 'DCRN', fill: 0x54c1a4, border: 0x0f7b62, alpha: 0.12 }, rgv: { label: 'RGV轨道', shortLabel: 'RGV', fill: 0xc691e9, border: 0x744b98, alpha: 0.12 }, annulus: { label: '环穿', shortLabel: 'ANNULUS', fill: 0xe6b3b3, border: 0xcc6666, alpha: 0 } }; function normalizeVector(p1, p2) { var dx = p2.x - p1.x; var dy = p2.y - p1.y; var length = Math.sqrt(dx * dx + dy * dy); return { x: dx / length, y: dy / length }; } function calcDistance(p1, p2) { return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); } function safeParseJson(text) { if (!text || typeof text !== 'string') { return null; } try { return JSON.parse(text); } catch (e) { return null; } } /** @param {string|object|null|undefined} raw */ function parseDeviceFormValue(raw) { if (!raw) { return null; } if (typeof raw === 'object') { return raw; } return safeParseJson(raw); } /** * 设备长宽:有 `deviceLength`/`deviceWidth` 则用像素值,否则用轨道自动尺寸。 * @param {object|null|undefined} item * @param {number} fallbackAlong * @param {number} fallbackAcross * @returns {{ along: number, across: number }} */ function normalizeDeviceSizeOverride(item, fallbackAlong, fallbackAcross) { if (!item) { return { along: fallbackAlong, across: fallbackAcross }; } var overrideAlong = Math.round(Number(item.deviceLength)); var overrideAcross = Math.round(Number(item.deviceWidth)); var along = isFinite(overrideAlong) && overrideAlong > 0 ? overrideAlong : fallbackAlong; var across = isFinite(overrideAcross) && overrideAcross > 0 ? overrideAcross : fallbackAcross; return { along: Math.max(2, along), across: Math.max(2, across) }; } function getNormalizeAngle(angle, startAngle, endAngle) { // 将 angle 归一化到 [startAngle, startAngle + 2*PI) 范围 var twoPi = 2 * Math.PI; var rangeStart = startAngle; var normalized = angle % twoPi; if (normalized < 0) { normalized += twoPi; } var normalizedStart = rangeStart % twoPi; if (normalizedStart < 0) { normalizedStart += twoPi; } // 计算相对偏移 var offset = normalized - normalizedStart; if (offset < 0) { offset += twoPi; } return rangeStart + offset; } function getPositionAfterMove(params) { var point = params.point; var pathList = params.pathList; var deltaDistance = params.deltaDistance; var angle = params.angle; var path, pathIndex; if (params.path) { path = params.path; pathIndex = pathList.indexOf(path); } else { pathIndex = params.pathIndex; path = pathList[pathIndex]; } var nextIndex = (pathIndex + 1) % pathList.length; var prevIndex = (pathIndex - 1 + pathList.length) % pathList.length; var isBackward = deltaDistance < 0; var moveDistance = Math.abs(deltaDistance); if (moveDistance === 0) { return { x: point.x, y: point.y, path: path, angle: angle }; } if (path.type === 'line') { var targetPoint = isBackward ? { x: path.startX, y: path.startY } : { x: path.x, y: path.y }; var restDistance = calcDistance(point, targetPoint); var vector = normalizeVector(point, targetPoint); if (moveDistance < restDistance) { // vector 指向本段目标端(正向为终点,反向为起点)。反向时 deltaDistance<0, // 必须用 +moveDistance 沿 vector 走,不能用 vector*deltaDistance(负号会把位移折向另一端)。 var stepAlong = isBackward ? moveDistance : deltaDistance; var x = point.x + vector.x * stepAlong; var y = point.y + vector.y * stepAlong; return { x: x, y: y, path: path }; } // pathList等于1说明就1条轨道,直接返回对应端点 if (pathList.length === 1) { return { x: targetPoint.x, y: targetPoint.y, path: path }; } return getPositionAfterMove({ point: targetPoint, pathList: pathList, pathIndex: isBackward ? prevIndex : nextIndex, deltaDistance: isBackward ? deltaDistance + restDistance : deltaDistance - restDistance }); } var startAngle = path.startAngle; var endAngle = path.endAngle; var inferredAngle = Math.atan2(point.y - path.y, point.x - path.x); var tmpCurrentAngle = angle != null ? angle : inferredAngle; var currentAngle = getNormalizeAngle(tmpCurrentAngle, startAngle, endAngle); var restDistance2 = isBackward ? Math.abs((currentAngle - startAngle) * path.radius) : Math.abs((endAngle - currentAngle) * path.radius); if (moveDistance < restDistance2) { var deltaAngle = (deltaDistance / path.radius) * -path.crossProduct; var newAngle = currentAngle + deltaAngle; return { x: path.x + path.radius * Math.cos(newAngle), y: path.y + path.radius * Math.sin(newAngle), path: path, angle: newAngle }; } return getPositionAfterMove({ point: isBackward ? { x: path.arcStartX, y: path.arcStartY } : { x: path.arcEndX, y: path.arcEndY }, pathList: pathList, pathIndex: isBackward ? prevIndex : nextIndex, deltaDistance: isBackward ? deltaDistance + restDistance2 : deltaDistance - restDistance2, angle: isBackward ? path.startAngle : path.endAngle }); } function getAllDistance(pathList) { var totalDistance = 0; pathList.forEach(function (path) { if (path.type === 'line') { totalDistance += calcDistance(path, { x: path.startX, y: path.startY }); } else { totalDistance += Math.abs( ((path.endAngle - path.startAngle) % (Math.PI * 2)) * path.radius ); } }); return totalDistance; } /** 相对轨道窄边(厚度方向)整体缩小车体,避免水平/垂直轨道上贴满轨道带 */ var TRACK_DEVICE_BOX_SCALE = 0.9; /** * 环穿单侧内缩距离:按车宽 across 的比例估算(优先 `value.deviceList[].deviceWidth`,否则环穿自动规则),且不低于下限。 * 元素可设 `annulusBandInset`(非负)覆盖。 */ var ANNULUS_INSET_FROM_DEVICE_RATIO = 0.26; /** @type {number} 默认最小内缩(像素) */ var ANNULUS_INSET_MIN_PIXELS = 5; /** * 直线轨道上设备图标像素尺寸(沿轨道方向 × 垂直轨道方向)。 * 与 drawCrnDeviceGraphics / drawRgvDeviceGraphics 的 width、height 语义一致,不再在别处做「再乘 2」。 * @param {{ width: number, height: number }} rect 轨道外接矩形 * @param {"crn"|"dualCrn"|"rgv"} trackType * @returns {{ along: number, across: number }} */ function getDevicePixelBoxForTrack(rect, trackType) { var shortLen = Math.min(rect.width, rect.height); var across = shortLen; var along = shortLen * 2; along = Math.round(along * TRACK_DEVICE_BOX_SCALE); across = Math.round(across * TRACK_DEVICE_BOX_SCALE); return { along: Math.max(2, along), across: Math.max(2, across) }; } /** * 轨道上设备「自动」像素尺寸(沿轨道方向 × 垂直轨道方向)。 * - 直线轨道:来自 getDevicePixelBoxForTrack * - 环穿:按历史逻辑缩放到 shortLength 的 15% * @param {{ type: string, width: number, height: number, pathList?: any[] }} rect * @returns {{ along: number, across: number } | null} */ function getAutoTrackDeviceBox(rect) { if (!rect) { return null; } var rectW = Number(rect.width); var rectH = Number(rect.height); if (!isFinite(rectW) || !isFinite(rectH) || rectW <= 0 || rectH <= 0) { return null; } if (rect.type === 'annulus') { var shortLength = Math.min(rectW, rectH); var box = getDevicePixelBoxForTrack({ width: shortLength, height: shortLength }, 'rgv'); var scale = (shortLength * 0.15) / box.across; return { along: Math.max(2, Math.round(box.along * scale)), across: Math.max(2, Math.round(box.across * scale)) }; } return getDevicePixelBoxForTrack({ width: rectW, height: rectH }, rect.type); } function getDeviceInfo(rect) { var isHorizontal = rect.width > rect.height; var longLength = isHorizontal ? rect.width : rect.height; var shortLength = isHorizontal ? rect.height : rect.width; var deviceForm = parseDeviceFormValue(rect.value); if (!deviceForm || !deviceForm.deviceList || deviceForm.deviceList.length === 0) { return deviceForm; } if (rect.type === 'annulus') { var pathList = rect.pathList || []; if (!pathList.length) { return deviceForm; } var allDistance = getAllDistance(pathList); var autoBox = getAutoTrackDeviceBox(rect); var ab = annulusBandContext(rect, rect.shape || 'rect'); deviceForm.deviceList.forEach(function (item) { var deltaDistance = (allDistance * item.progress) / 100; var startPoint = { x: pathList[0].arcStartX, y: pathList[0].arcStartY }; var moved = getPositionAfterMove({ point: startPoint, pathList: pathList, pathIndex: 0, deltaDistance: deltaDistance }); var centered = shiftAnnulusPointToBandCenter( moved.x, moved.y, moved.path, ab.inset, ab.refInside ); var fallbackAlong = autoBox ? autoBox.along : Math.max(2, Math.round(shortLength * 0.3)); var fallbackAcross = autoBox ? autoBox.across : Math.max(2, Math.round(shortLength * 0.15)); var size = normalizeDeviceSizeOverride(item, fallbackAlong, fallbackAcross); var width = size.along; var height = size.across; item.x = centered.x; item.y = centered.y; item.path = moved.path; item.width = width; item.height = height; }); return deviceForm; } var box = getAutoTrackDeviceBox(rect) || getDevicePixelBoxForTrack(rect, rect.type); deviceForm.deviceList.forEach(function (item) { var size = normalizeDeviceSizeOverride(item, box.along, box.across); var inset = (shortLength - size.across) / 2; var distance = (item.progress * (longLength - 2 * shortLength)) / 100; if (isHorizontal) { item.x = rect.x + distance; item.y = rect.y + inset; item.width = size.along; item.height = size.across; } else { item.x = rect.x + inset; item.y = rect.y + distance; item.width = size.across; item.height = size.along; } }); return deviceForm; } function getLShapePointList(sprite) { var turningPoint; var LPointList; if (!sprite.turningPoint) { var rate = 1 / 3; var minDistance = Math.min(sprite.width, sprite.height); var shortDistance = rate * minDistance; var longDistance = sprite.height - shortDistance; if (sprite.shape === 'L1') { turningPoint = { x: sprite.x + shortDistance, y: sprite.y + longDistance }; } else if (sprite.shape === 'L2') { turningPoint = { x: sprite.x + sprite.width - shortDistance, y: sprite.y + sprite.height - longDistance }; } else if (sprite.shape === 'L3') { turningPoint = { x: sprite.x + shortDistance, y: sprite.y + sprite.height - longDistance }; } else if (sprite.shape === 'L4') { turningPoint = { x: sprite.x + sprite.width - shortDistance, y: sprite.y + longDistance }; } } else { turningPoint = sprite.turningPoint; } sprite.turningPoint = turningPoint; if (sprite.shape === 'L1') { LPointList = [ { x: sprite.x, y: sprite.y, direction: 'up' }, { x: turningPoint.x, y: sprite.y, direction: 'right' }, Object.assign({}, turningPoint, { direction: 'down' }), { x: sprite.x + sprite.width, y: turningPoint.y, direction: 'right' }, { x: sprite.x + sprite.width, y: sprite.y + sprite.height, direction: 'down' }, { x: sprite.x, y: sprite.y + sprite.height, direction: 'left' } ]; } else if (sprite.shape === 'L2') { LPointList = [ { x: sprite.x + sprite.width, y: sprite.y + sprite.height, direction: 'bottom' }, { x: turningPoint.x, y: sprite.y + sprite.height, direction: 'left' }, Object.assign({}, turningPoint, { direction: 'up' }), { x: sprite.x, y: turningPoint.y, direction: 'left' }, { x: sprite.x, y: sprite.y, direction: 'up' }, { x: sprite.x + sprite.width, y: sprite.y, direction: 'right' } ]; } else if (sprite.shape === 'L3') { LPointList = [ { x: sprite.x, y: sprite.y + sprite.height, direction: 'bottom' }, { x: turningPoint.x, y: sprite.y + sprite.height, direction: 'right' }, Object.assign({}, turningPoint, { direction: 'up' }), { x: sprite.x + sprite.width, y: turningPoint.y, direction: 'right' }, { x: sprite.x + sprite.width, y: sprite.y, direction: 'up' }, { x: sprite.x, y: sprite.y, direction: 'left' } ]; } else { LPointList = [ { x: sprite.x + sprite.width, y: sprite.y, direction: 'up' }, { x: turningPoint.x, y: sprite.y, direction: 'left' }, Object.assign({}, turningPoint, { direction: 'down' }), { x: sprite.x, y: turningPoint.y, direction: 'left' }, { x: sprite.x, y: sprite.y + sprite.height, direction: 'down' }, { x: sprite.x + sprite.width, y: sprite.y + sprite.height, direction: 'right' } ]; } return LPointList; } /** * 射线法判点是否在简单多边形内(用于找参考内点,算内向法向)。 * @param {{ x: number, y: number }} pt * @param {{ x: number, y: number }[]} ring * @returns {boolean} */ function pointInPolygon(pt, ring) { var inside = false; var n = ring.length; var i; for (i = 0; i < n; i++) { var a = ring[i]; var b = ring[(i + 1) % n]; var intersects = a.y > pt.y !== b.y > pt.y && pt.x < ((b.x - a.x) * (pt.y - a.y)) / (b.y - a.y + 1e-12) + a.x; if (intersects) { inside = !inside; } } return inside; } /** * 在尖点环包围盒内采样,得到多边形内部一点,供直线段上判断「指向内侧」的法向。 * @param {{ x: number, y: number }[]} pointList * @returns {{ x: number, y: number }} */ function findInteriorRefPoint(pointList) { var minX = Infinity; var minY = Infinity; var maxX = -Infinity; var maxY = -Infinity; var i; for (i = 0; i < pointList.length; i++) { var p = pointList[i]; minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); } var cx = (minX + maxX) / 2; var cy = (minY + maxY) / 2; if (pointInPolygon({ x: cx, y: cy }, pointList)) { return { x: cx, y: cy }; } var g; for (g = 1; g <= 6; g++) { var sj; for (sj = 1; sj < g; sj++) { var si; for (si = 1; si < g; si++) { var tx = minX + ((maxX - minX) * si) / g; var ty = minY + ((maxY - minY) * sj) / g; if (pointInPolygon({ x: tx, y: ty }, pointList)) { return { x: tx, y: ty }; } } } } return { x: cx, y: cy }; } /** * 有向边 (ax,ay)→(bx,by) 沿环前进方向的单位内向法向量(逆时针环时内侧在前进方向左侧)。 * @param {number} ax * @param {number} ay * @param {number} bx * @param {number} by * @param {boolean} ccwPolygon 尖点环有向面积 >0 时为逆时针 * @returns {{ x: number, y: number }} */ function inwardNormalUnitForward(ax, ay, bx, by, ccwPolygon) { var dx = bx - ax; var dy = by - ay; var len = Math.sqrt(dx * dx + dy * dy) || 1; var ux = dx / len; var uy = dy / len; if (ccwPolygon) { return { x: -uy, y: ux }; } return { x: uy, y: -ux }; } /** * 两直线 p1+t*u1 与 p2+s*u2 的交点(u 已为单位向量则 t 为几何长度)。 * @param {{ x: number, y: number }} p1 * @param {{ x: number, y: number }} u1 * @param {{ x: number, y: number }} p2 * @param {{ x: number, y: number }} u2 * @returns {{ x: number, y: number }} */ function intersectLines(p1, u1, p2, u2) { var cr = u1.x * u2.y - u1.y * u2.x; if (Math.abs(cr) < 1e-9) { return { x: p1.x, y: p1.y }; } var dx = p2.x - p1.x; var dy = p2.y - p1.y; var t = (dx * u2.y - dy * u2.x) / cr; return { x: p1.x + t * u1.x, y: p1.y + t * u1.y }; } /** * 直角顶点环向内偏移。 * 凹角:沿环前进向量 vIn=curr−prev、vOut=next−curr 的叉积与环向一致时判定(CCW 环上凸角 cross>0、凹角 cross<0), * 新顶点为 curr+inset*(nIn+nOut);凸角为两邻边平移后的直线交点。 * (smoothRightAnglePath 用的是 curr→prev 与 curr→next,与「沿环前进」差一符号,不能复用其叉积判凹凸。) * @param {{ x: number, y: number, direction?: string }[]} sharpCorners * @param {number} inset * @returns {{ x: number, y: number, direction?: string }[]} */ function offsetOrthogonalSharpRing(sharpCorners, inset) { var area2 = 0; var nj; for (nj = 0; nj < sharpCorners.length; nj++) { var pj0 = sharpCorners[nj]; var pj1 = sharpCorners[(nj + 1) % sharpCorners.length]; area2 += pj0.x * pj1.y - pj1.x * pj0.y; } var ccwPolygon = area2 > 0; var n = sharpCorners.length; var out = []; var i; for (i = 0; i < n; i++) { var prev = sharpCorners[(i - 1 + n) % n]; var curr = sharpCorners[i]; var next = sharpCorners[(i + 1) % n]; var vInx = curr.x - prev.x; var vIny = curr.y - prev.y; var vOutx = next.x - curr.x; var vOuty = next.y - curr.y; var crossWalk = vInx * vOuty - vIny * vOutx; var reflex = ccwPolygon ? crossWalk < 0 : crossWalk > 0; var nIn = inwardNormalUnitForward(prev.x, prev.y, curr.x, curr.y, ccwPolygon); var nOut = inwardNormalUnitForward(curr.x, curr.y, next.x, next.y, ccwPolygon); var ix; var iy; if (reflex) { ix = curr.x + (nIn.x + nOut.x) * inset; iy = curr.y + (nIn.y + nOut.y) * inset; } else { var lenIn = Math.sqrt(vInx * vInx + vIny * vIny) || 1; var lenOut = Math.sqrt(vOutx * vOutx + vOuty * vOuty) || 1; var uIn = { x: vInx / lenIn, y: vIny / lenIn }; var uOut = { x: vOutx / lenOut, y: vOuty / lenOut }; var oIn = { x: prev.x + nIn.x * inset, y: prev.y + nIn.y * inset }; var oOut = { x: curr.x + nOut.x * inset, y: curr.y + nOut.y * inset }; var inter = intersectLines(oIn, uIn, oOut, uOut); ix = inter.x; iy = inter.y; } out.push({ x: ix, y: iy, direction: sharpCorners[i].direction }); } return out; } /** * 解析环穿单侧内缩像素距离;若传入 `precomputedSharp` 则不再重复求尖点环。 * @param {{ width: number, height: number, shape?: string, annulusBandInset?: number, value?: string|object }} sprite * @param {{ x: number, y: number }[]} [precomputedSharp] * @returns {number} */ // function resolveAnnulusBandInset(sprite, precomputedSharp) { // var override = sprite && sprite.annulusBandInset; // if (typeof override === 'number' && isFinite(override) && override >= 0) { // return override; // } // var tmp = { type: 'annulus', width: sprite.width, height: sprite.height }; // var box = getAutoTrackDeviceBox(tmp); // var shortLen = Math.min(Number(sprite.width) || 0, Number(sprite.height) || 0); // var fallbackAlong = box ? box.along : Math.max(2, Math.round(shortLen * 0.3)); // var fallbackAcross = box ? box.across : Math.max(2, Math.round(shortLen * 0.15)); // var form = parseDeviceFormValue(sprite && sprite.value); // var across = fallbackAcross; // console.log('fallbackAcross', across); // if (form && form.deviceList && form.deviceList.length) { // var di; // for (di = 0; di < form.deviceList.length; di++) { // var sz = normalizeDeviceSizeOverride(form.deviceList[di], fallbackAlong, fallbackAcross); // if (sz.across > across) { // across = sz.across; // } // } // } // var inset = Math.max( // ANNULUS_INSET_MIN_PIXELS, // Math.round(across * ANNULUS_INSET_FROM_DEVICE_RATIO) // ); // var maxByBox = Math.floor(Math.min(sprite.width, sprite.height) / 2) - 3; // var ring = precomputedSharp || getSharpCornerList(sprite, sprite.shape || 'rect'); // var minEdge = Infinity; // var ri; // for (ri = 0; ri < ring.length; ri++) { // var d = calcDistance(ring[ri], ring[(ri + 1) % ring.length]); // if (d > 0 && d < minEdge) { // minEdge = d; // } // } // var maxByEdge = minEdge === Infinity ? inset : Math.floor(minEdge / 2) - 1; // var cap = Math.min(isFinite(maxByBox) ? maxByBox : inset, isFinite(maxByEdge) ? maxByEdge : inset); // if (cap > 0 && inset > cap) { // inset = cap; // } // return Math.max(0, inset); // } /** * 环穿共用:尖点环、内侧参考点、inset(只算一遍尖点,避免多处重复 getSharpCornerList)。 * @param {object} sprite x/y/width/height/shape/turningPoint/annulusBandInset * @param {string} defaultShape * @returns {{ sharp: object[], refInside: {x:number,y:number}, inset: number }} */ function annulusBandContext(sprite, defaultShape) { sprite.shape = sprite.shape || defaultShape; var sharp = getSharpCornerList(sprite, defaultShape); return { sharp: sharp, refInside: findInteriorRefPoint(sharp), inset: 6 }; } /** * 由元素外接框与形状得到未圆角前的顶点环(rect 或 L1–L4)。 * @param {{ x: number, y: number, width: number, height: number, shape?: string, turningPoint?: object }} sprite * @param {string} defaultShape * @returns {{ x: number, y: number, direction?: string }[]} */ function getSharpCornerList(sprite, defaultShape) { var rectPointList = [ { x: sprite.x, y: sprite.y, direction: 'right' }, { x: sprite.x + sprite.width, y: sprite.y, direction: 'down' }, { x: sprite.x + sprite.width, y: sprite.y + sprite.height, direction: 'left' }, { x: sprite.x, y: sprite.y + sprite.height, direction: 'up' } ]; var shape = sprite.shape || defaultShape; return shape === 'rect' ? rectPointList : getLShapePointList(sprite); } function getIsStillHalf(target) { var curList = getLShapePointList(target); var count = 0; for (var i = 0; i < curList.length; i++) { var p0 = curList[i]; var p1 = curList[(i + 1) % curList.length]; var p2 = curList[(i + 2) % curList.length]; var p3 = curList[(i + 3) % curList.length]; var d1 = calcDistance(p0, p1); var d2 = calcDistance(p1, p2); var d3 = calcDistance(p2, p3); if (target.halfList.includes((i + 1) % curList.length) && d2 <= d1 && d2 <= d3) { count += 1; } } return count === 2; } function setRadiusInPoint(pointList, sprite) { sprite.halfList = []; var pointLength = pointList.length; var i; for (i = 0; i < pointLength; i++) { var currentPoint = pointList[i]; var next1 = pointList[(i + 1) % pointLength]; var next2 = pointList[(i + 2) % pointLength]; var next3 = pointList[(i + 3) % pointLength]; var v1 = normalizeVector(currentPoint, next1); var v2 = normalizeVector(next1, next2); var v3 = normalizeVector(next2, next3); var finalV = { x: v1.x + v2.x + v3.x, y: v1.y + v2.y + v3.y }; var distance = finalV.x * finalV.x + finalV.y * finalV.y; var d1 = calcDistance(currentPoint, next1); var d2 = calcDistance(next1, next2); var d3 = calcDistance(next2, next3); if (distance === 1 && d2 <= d1 && d2 <= d3) { var radius = d2 / 2; next1.isHalf = true; next2.isHalf = true; next1.radius = radius; next2.radius = radius; sprite.halfList.push((i + 1) % pointLength); } } for (i = 0; i < pointLength; i++) { currentPoint = pointList[i]; if (currentPoint.isHalf) { continue; } var prevPoint = pointList[(i - 1 + pointLength) % pointLength]; var nextPoint = pointList[(i + 1 + pointLength) % pointLength]; var rad = Math.min.apply( Math, [prevPoint.radius, nextPoint.radius].filter(function (item) { return item; }) ); currentPoint.radius = rad; } } function smoothRightAnglePath(points) { var smoothedPath = []; var n = points.length; var i; for (i = 0; i < n; i++) { var prev = points[(i - 1 + n) % n]; var curr = points[i]; var next = points[(i + 1) % n]; var radius = curr.radius; var toPrev = normalizeVector(curr, prev); var toNext = normalizeVector(curr, next); var dotProduct = toPrev.x * toNext.x + toPrev.y * toNext.y; var isRightAngle = Math.abs(dotProduct) < 0.1; if (isRightAngle) { var arcStart = { x: curr.x + toPrev.x * radius, y: curr.y + toPrev.y * radius }; var arcEnd = { x: curr.x + toNext.x * radius, y: curr.y + toNext.y * radius }; var arcCenter = { x: curr.x + toPrev.x * radius + toNext.x * radius, y: curr.y + toPrev.y * radius + toNext.y * radius }; var startAngle = Math.atan2(arcStart.y - arcCenter.y, arcStart.x - arcCenter.x); var endAngle = Math.atan2(arcEnd.y - arcCenter.y, arcEnd.x - arcCenter.x); var crossProduct = toPrev.x * toNext.y - toPrev.y * toNext.x; if (crossProduct > 0) { if (endAngle > startAngle) { endAngle -= Math.PI * 2; } } else { if (endAngle < startAngle) { endAngle += Math.PI * 2; } } smoothedPath.push({ x: arcCenter.x, y: arcCenter.y, radius: radius, startAngle: startAngle, endAngle: endAngle, type: 'arc', arcStartX: arcStart.x, arcStartY: arcStart.y, arcEndX: arcEnd.x, arcEndY: arcEnd.y, crossProduct: crossProduct }); } } var length = smoothedPath.length; var newSmoothedPath = []; for (i = 0; i < length; i++) { var currArc = smoothedPath[i]; var nextArc = smoothedPath[(i + 1 + length) % length]; newSmoothedPath.push(currArc); if (currArc.arcEndX !== nextArc.arcStartX || currArc.arcEndY !== nextArc.arcStartY) { newSmoothedPath.push({ startX: currArc.arcEndX, startY: currArc.arcEndY, type: 'line', x: nextArc.arcStartX, y: nextArc.arcStartY }); } } return newSmoothedPath; } /** * 仅描边/填充轮廓:沿 pathList 走笔并 closePath(不 endFill,便于连续多圈描边)。 * @param {PIXI.Graphics} smoothedGraphics * @param {object[]} path */ function traceSmoothedPath(smoothedGraphics, path) { if (!path || !path.length) { return; } var startPoint = path[0]; if (startPoint.type === 'arc') { smoothedGraphics.moveTo(startPoint.arcStartX, startPoint.arcStartY); } else { smoothedGraphics.moveTo(startPoint.x, startPoint.y); } var i; for (i = 0; i < path.length; i++) { var point = path[i]; if (point.type === 'line') { smoothedGraphics.lineTo(point.x, point.y); } else if (point.type === 'arc') { smoothedGraphics.arc( point.x, point.y, point.radius, point.startAngle, point.endAngle, point.endAngle < point.startAngle ); } } smoothedGraphics.closePath(); } /** * 填充闭合外圈并 endFill(调用方需已 beginFill)。 * @param {PIXI.Graphics} smoothedGraphics * @param {object[]} path */ function drawSmoothedPath(smoothedGraphics, path) { traceSmoothedPath(smoothedGraphics, path); smoothedGraphics.endFill(); } /** * PIXI beginHole 要求洞与外轮廓绕向相反:段逆序且圆弧起终点对调。 * @param {object[]} path smoothRightAnglePath 结果 * @returns {object[]} */ function reverseSmoothedSegmentsForHole(path) { var n = path.length; var out = []; var i; for (i = n - 1; i >= 0; i--) { var seg = path[i]; if (seg.type === 'line') { out.push({ type: 'line', startX: seg.x, startY: seg.y, x: seg.startX, y: seg.startY }); } else if (seg.type === 'arc') { out.push({ type: 'arc', x: seg.x, y: seg.y, radius: seg.radius, startAngle: seg.endAngle, endAngle: seg.startAngle, arcStartX: seg.arcEndX, arcStartY: seg.arcEndY, arcEndX: seg.arcStartX, arcEndY: seg.arcStartY, crossProduct: seg.crossProduct }); } } return out; } /** * 外填充 + 内洞(双轨带),调用方需已 beginFill。 * @param {PIXI.Graphics} smoothedGraphics * @param {object[]} outerPath * @param {object[]} innerPath */ function drawSmoothedPathWithHole(smoothedGraphics, outerPath, innerPath) { traceSmoothedPath(smoothedGraphics, outerPath); smoothedGraphics.beginHole(); traceSmoothedPath(smoothedGraphics, reverseSmoothedSegmentsForHole(innerPath)); smoothedGraphics.endHole(); smoothedGraphics.endFill(); } /** * 生成外圈与内圈 smooth pathList;内圈由正交尖点环 offset 后再 setRadius/smooth。 * @param {{ x: number, y: number, width: number, height: number, shape?: string, turningPoint?: object, annulusBandInset?: number }} sprite * @param {string} defaultShape * @returns {{ outerPath: object[], innerPath: object[]|null, inset: number }} */ function buildOuterAndInnerPaths(sprite, defaultShape) { var ctx = annulusBandContext(sprite, defaultShape); var sharp = ctx.sharp; var sharpClone = sharp.map(function (p) { return { x: p.x, y: p.y, direction: p.direction }; }); setRadiusInPoint(sharp, sprite); var outerPath = smoothRightAnglePath(sharp); var inset = ctx.inset; var minSide = Math.min(sprite.width, sprite.height); if (inset <= 0 || minSide <= inset * 2 + 4) { return { outerPath: outerPath, innerPath: null, inset: inset }; } var innerSharp = offsetOrthogonalSharpRing(sharpClone, inset); var innerMeta = {}; setRadiusInPoint(innerSharp, innerMeta); var innerPath = smoothRightAnglePath(innerSharp); return { outerPath: outerPath, innerPath: innerPath, inset: inset }; } /** * 外轮廓上一点沿法向(直线段)或径向(圆弧段)内移 half,落在双轨几何中线。 * @param {number} x * @param {number} y * @param {object} path 当前段 line|arc * @param {number} inset 单侧带宽 * @param {{ x: number, y: number }} refInside 多边形内参考点 * @returns {{ x: number, y: number }} */ function shiftAnnulusPointToBandCenter(x, y, path, inset, refInside) { var half = inset / 2; if (half <= 0) { return { x: x, y: y }; } if (path.type === 'line') { var tx = path.x - path.startX; var ty = path.y - path.startY; var tlen = Math.sqrt(tx * tx + ty * ty) || 1; var nx = -ty / tlen; var ny = tx / tlen; var mx = (path.startX + path.x) / 2; var my = (path.startY + path.y) / 2; if ((refInside.x - mx) * nx + (refInside.y - my) * ny < 0) { nx = -nx; ny = -ny; } return { x: x + nx * half, y: y + ny * half }; } if (path.type === 'arc') { var ox = x - path.x; var oy = y - path.y; var olen = Math.sqrt(ox * ox + oy * oy) || 1; return { x: x - (ox / olen) * half, y: y - (oy / olen) * half }; } return { x: x, y: y }; } /** * 将外圈 path 上坐标映射到轨道带中线(监控/条码与编辑器一致)。 * @param {{ type: string, x: number, y: number, width: number, height: number, shape?: string, turningPoint?: object, annulusBandInset?: number }} element * @param {number} x * @param {number} y * @param {object} path * @returns {{ x: number, y: number }} */ function centerAnnulusBandPoint(element, x, y, path) { if (!element || element.type !== 'annulus' || path == null) { return { x: x, y: y }; } var ctx = annulusBandContext(element, element.shape || 'rect'); return shiftAnnulusPointToBandCenter(x, y, path, ctx.inset, ctx.refInside); } /** * 将坐标压回环穿外圈 path(直线段投影、圆弧段落到半径上)。 * sprite 已做 centerAnnulusBandPoint 时不能直接作为 getPositionAfterMove 的起点,否则会沿「点→段终点」斜移漂移。 * @param {number} x * @param {number} y * @param {{ type: string, startX?: number, startY?: number, x?: number, y?: number, radius?: number }|null|undefined} path * @returns {{ x: number, y: number }} */ function snapToAnnulusOuterPath(x, y, path) { if (!path) { return { x: x, y: y }; } if (path.type === 'line') { var sx = path.startX; var sy = path.startY; var ex = path.x; var ey = path.y; var dx = ex - sx; var dy = ey - sy; var len2 = dx * dx + dy * dy; if (len2 < 1e-12) { return { x: sx, y: sy }; } var t = ((x - sx) * dx + (y - sy) * dy) / len2; t = Math.max(0, Math.min(1, t)); return { x: sx + t * dx, y: sy + t * dy }; } if (path.type === 'arc') { var ox = path.x; var oy = path.y; var r = path.radius; if (!isFinite(r) || r <= 0) { return { x: x, y: y }; } var ang = getNormalizeAngle(Math.atan2(y - oy, x - ox), path.startAngle, path.endAngle); return { x: ox + r * Math.cos(ang), y: oy + r * Math.sin(ang) }; } return { x: x, y: y }; } /** * 监控轨道层:已保存的 pathList 描外圈 + 按几何重算内圈再描一圈。 * @param {PIXI.Graphics} smoothedGraphics * @param {{ pathList: object[], x: number, y: number, width: number, height: number, shape?: string, turningPoint?: object, annulusBandInset?: number }} item * @param {string} defaultShape */ function strokeAnnulusDualOutline(smoothedGraphics, item, defaultShape) { var outerPath = item && item.pathList; if (!outerPath || !outerPath.length) { return; } var tmp = { x: item.x, y: item.y, width: item.width, height: item.height, shape: item.shape, turningPoint: item.turningPoint, annulusBandInset: item.annulusBandInset }; var pair = buildOuterAndInnerPaths(tmp, item.shape || defaultShape); traceSmoothedPath(smoothedGraphics, outerPath); if (pair.innerPath && pair.innerPath.length) { traceSmoothedPath(smoothedGraphics, pair.innerPath); } } /** * 编辑器环穿填充:写回 sprite.pathList(外圈),绘制外填+内洞。 * PIXI 中带洞填充通常不会给洞边界描边,故在 endFill 后再沿内圈 trace 一遍,与监控轨 strokeAnnulusDualOutline 一致, * 使 trackLayer / guideLayer / selectionLayer 均能看到内圈轮廓。 * @param {PIXI.Graphics} smoothedGraphics * @param {object} sprite 元素或预览 rect * @param {string} defaultShape 如 annulusShape */ function startDrawSmoothedPath(smoothedGraphics, sprite, defaultShape) { var pair = buildOuterAndInnerPaths(sprite, defaultShape); sprite.pathList = pair.outerPath; if (!pair.innerPath || !pair.innerPath.length) { drawSmoothedPath(smoothedGraphics, pair.outerPath); return; } drawSmoothedPathWithHole(smoothedGraphics, pair.outerPath, pair.innerPath); traceSmoothedPath(smoothedGraphics, pair.innerPath); } function getRotate(point, path) { var vector = normalizeVector(point, path); if (path.type === 'arc') { var angleToCenter = Math.atan2(vector.y, vector.x); return angleToCenter + (Math.PI / 2) * path.crossProduct; } return Math.atan2(vector.y, vector.x); } /** * 堆垛机 / 双工位设备图标(与 MapCanvas#createCrnTexture / createCrnTextureColoredDevice 一致) * @param {PIXI.Graphics} g * @param {number} deviceWidth 图标总宽(像素,与 getDevicePixelBoxForTrack.along 一致) * @param {number} deviceHeight 图标总高(与 getDevicePixelBoxForTrack.across 一致) * @param {number} bodyColor 驾驶舱填充色 */ function drawCrnDeviceGraphics(g, deviceWidth, deviceHeight, bodyColor) { var yTop = Math.round(deviceHeight * 0.1); g.beginFill(0x999999); g.drawRect(2, yTop, 3, deviceHeight - yTop - 2); g.drawRect(deviceWidth - 5, yTop, 3, deviceHeight - yTop - 2); g.endFill(); g.beginFill(0x999999); g.drawRect(0, yTop, deviceWidth, 3); g.endFill(); var cabW = Math.round(deviceWidth * 0.68); var cabH = Math.round(deviceHeight * 0.38); var cabX = Math.round((deviceWidth - cabW) / 2); var cabY = Math.round(deviceHeight * 0.52 - cabH / 2); g.beginFill(bodyColor); g.drawRect(cabX, cabY, cabW, cabH); g.endFill(); var winW = Math.round(cabW * 0.6); var winH = Math.round(cabH * 0.45); var winX = cabX + Math.round((cabW - winW) / 2); var winY = cabY + Math.round((cabH - winH) / 2); g.beginFill(0xd0e8ff); g.drawRect(winX, winY, winW, winH); g.endFill(); var forkW = Math.round(deviceWidth * 0.8); var forkH = Math.max(2, Math.round(deviceHeight * 0.08)); var forkX = Math.round((deviceWidth - forkW) / 2); var forkY = cabY + cabH; g.beginFill(0x666666); g.drawRect(forkX, forkY, forkW, forkH); g.endFill(); } /** * RGV 设备图标(与 MapCanvas#createRgvTextureColoredDevice 一致) * @param {PIXI.Graphics} g * @param {number} width * @param {number} height * @param {number} bodyColor 车体填充色 */ function drawRgvDeviceGraphics(g, width, height, bodyColor) { var bodyW = Math.round(width * 0.8); var bodyH = Math.round(height * 0.55); var bodyX = Math.round((width - bodyW) / 2); var bodyY = Math.round((height - bodyH) / 2); g.beginFill(bodyColor); g.drawRect(bodyX, bodyY, bodyW, bodyH); g.endFill(); var winW = Math.round(bodyW * 0.55); var winH = Math.round(bodyH * 0.45); var winX = bodyX + Math.round((bodyW - winW) / 2); var winY = bodyY + Math.round((bodyH - winH) / 2); g.beginFill(0xd0e8ff); g.drawRect(winX, winY, winW, winH); g.endFill(); var wheelW = Math.max(2, Math.round(width * 0.12)); var wheelH = Math.max(2, Math.round(height * 0.1)); var wheelY = bodyY + bodyH; var wheelGap = Math.round((width - wheelW * 2) / 3); var wheelX1 = wheelGap; var wheelX2 = width - wheelGap - wheelW; g.beginFill(0x333333); g.drawRect(wheelX1, wheelY - Math.round(wheelH / 2), wheelW, wheelH); g.drawRect(wheelX2, wheelY - Math.round(wheelH / 2), wheelW, wheelH); g.endFill(); } global.BasMapTrackGeometry = { TYPE_META: TYPE_META, normalizeVector: normalizeVector, calcDistance: calcDistance, safeParseJson: safeParseJson, getNormalizeAngle: getNormalizeAngle, getPositionAfterMove: getPositionAfterMove, getAllDistance: getAllDistance, getDeviceInfo: getDeviceInfo, getDevicePixelBoxForTrack: getDevicePixelBoxForTrack, getAutoTrackDeviceBox: getAutoTrackDeviceBox, getLShapePointList: getLShapePointList, getIsStillHalf: getIsStillHalf, setRadiusInPoint: setRadiusInPoint, smoothRightAnglePath: smoothRightAnglePath, drawSmoothedPath: drawSmoothedPath, traceSmoothedPath: traceSmoothedPath, strokeAnnulusDualOutline: strokeAnnulusDualOutline, centerAnnulusBandPoint: centerAnnulusBandPoint, snapToAnnulusOuterPath: snapToAnnulusOuterPath, startDrawSmoothedPath: startDrawSmoothedPath, getRotate: getRotate, drawCrnDeviceGraphics: drawCrnDeviceGraphics, drawRgvDeviceGraphics: drawRgvDeviceGraphics }; })(typeof window !== 'undefined' ? window : this); src/main/webapp/views/basMap/editor.html
@@ -1,6 +1,6 @@ <!DOCTYPE html> <html lang="zh-CN"> <head> <head> <meta charset="utf-8"> <title>自由画布地图编辑器</title> <meta name="renderer" content="webkit"> @@ -9,797 +9,1178 @@ <link rel="stylesheet" href="../../static/vue/element/element.css"> <link rel="stylesheet" href="../../static/css/cool.css"> <style> :root { --page-bg: radial-gradient(1180px 540px at -10% -16%, rgba(24, 113, 181, 0.14), transparent 58%), radial-gradient(920px 480px at 110% -12%, rgba(14, 148, 136, 0.12), transparent 56%), linear-gradient(180deg, #eef4f9 0%, #f8fbfd 100%); --card-bg: rgba(255, 255, 255, 0.94); --card-border: rgba(216, 226, 238, 0.96); --text-main: #213448; --text-sub: #63788e; --primary: #2f79d6; --accent: #169a82; --warn: #f08a3c; --danger: #d85a5a; } :root { --page-bg: radial-gradient(1180px 540px at -10% -16%, rgba(24, 113, 181, 0.14), transparent 58%), radial-gradient(920px 480px at 110% -12%, rgba(14, 148, 136, 0.12), transparent 56%), linear-gradient(180deg, #eef4f9 0%, #f8fbfd 100%); --card-bg: rgba(255, 255, 255, 0.94); --card-border: rgba(216, 226, 238, 0.96); --text-main: #213448; --text-sub: #63788e; --primary: #2f79d6; --accent: #169a82; --warn: #f08a3c; --danger: #d85a5a; } [v-cloak] { display: none; } [v-cloak] { display: none; } html, body { margin: 0; height: 100%; font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif; color: var(--text-main); background: var(--page-bg); overflow: hidden; } html, body { margin: 0; height: 100%; font-family: 'Avenir Next', 'PingFang SC', 'Microsoft YaHei', sans-serif; color: var(--text-main); background: var(--page-bg); overflow: hidden; } .editor-shell { width: 100%; height: 100vh; margin: 0 auto; padding: 8px; box-sizing: border-box; display: flex; flex-direction: column; gap: 0; } .editor-shell { width: 100%; height: 100vh; margin: 0 auto; padding: 8px; box-sizing: border-box; display: flex; flex-direction: column; gap: 0; } .panel-card { border-radius: 24px; border: 1px solid var(--card-border); background: var(--card-bg); box-shadow: 0 16px 32px rgba(39, 62, 92, 0.08); } .panel-card { border-radius: 24px; border: 1px solid var(--card-border); background: var(--card-bg); box-shadow: 0 16px 32px rgba(39, 62, 92, 0.08); } .workspace { min-height: 0; flex: 1 1 auto; display: flex; } .workspace { min-height: 0; flex: 1 1 auto; display: flex; } .panel-card { display: flex; flex-direction: column; overflow: hidden; } .panel-card { display: flex; flex-direction: column; overflow: hidden; } .panel-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 18px 20px 14px; border-bottom: 1px solid rgba(221, 230, 239, 0.94); } .panel-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 18px 20px 14px; border-bottom: 1px solid rgba(221, 230, 239, 0.94); } .panel-head h2 { margin: 0; font-size: 16px; font-weight: 700; } .panel-head h2 { margin: 0; font-size: 16px; font-weight: 700; } .panel-body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 14px; min-height: 0; overflow: auto; } .panel-body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 14px; min-height: 0; overflow: auto; } .tool-section, .status-stack, .action-list { display: flex; flex-direction: column; gap: 8px; } .tool-section, .status-stack, .action-list { display: flex; flex-direction: column; gap: 8px; } .tool-section-label { font-size: 12px; color: var(--text-sub); letter-spacing: 0.08em; text-transform: uppercase; } .tool-section-label { font-size: 12px; color: var(--text-sub); letter-spacing: 0.08em; text-transform: uppercase; } .tool-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .tool-section-label-sub { font-size: 12px; color: var(--text-sub); letter-spacing: 0.08em; text-transform: uppercase; line-height: 1.75; } .tool-card-btn { appearance: none; border: 1px solid rgba(193, 205, 219, 0.9); background: rgba(255, 255, 255, 0.96); border-radius: 14px; padding: 10px 12px; text-align: left; cursor: pointer; transition: all 0.18s ease; color: var(--text-main); display: flex; flex-direction: column; gap: 4px; min-height: 68px; } .tool-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .tool-card-btn.active { border-color: rgba(55, 127, 212, 0.45); background: rgba(235, 244, 253, 0.98); box-shadow: 0 8px 18px rgba(47, 121, 214, 0.14); } .tool-el-popover { height: 100%; } .tool-card-btn strong { font-size: 13px; font-weight: 700; } .tool-el-popover > .el-popover__reference-wrapper { display: block; height: 100%; } .tool-card-btn span { font-size: 12px; color: var(--text-sub); line-height: 1.5; } .tool-el-popover .tool-card-btn { height: 100%; } .status-card, .selection-summary, .note-card { border-radius: 16px; border: 1px solid rgba(218, 227, 236, 0.92); background: rgba(248, 251, 254, 0.92); padding: 12px 14px; } .tool-card-btn { appearance: none; border: 1px solid rgba(193, 205, 219, 0.9); background: rgba(255, 255, 255, 0.96); border-radius: 14px; padding: 10px 12px; text-align: left; cursor: pointer; transition: all 0.18s ease; color: var(--text-main); display: flex; flex-direction: column; gap: 4px; min-height: 68px; } .status-card strong, .selection-summary strong, .note-card strong { display: block; font-size: 13px; margin-bottom: 6px; } .tool-card-btn.active { border-color: rgba(55, 127, 212, 0.45); background: rgba(235, 244, 253, 0.98); box-shadow: 0 8px 18px rgba(47, 121, 214, 0.14); } .status-card span, .selection-summary span, .note-card span { display: block; font-size: 12px; color: var(--text-sub); line-height: 1.65; } .tool-card-btn strong { font-size: 13px; font-weight: 700; } .selection-summary strong { font-size: 14px; color: var(--text-main); } .tool-card-btn span { font-size: 12px; color: var(--text-sub); line-height: 1.5; } .canvas-toolbar { padding: 16px 18px 14px; border-bottom: 1px solid rgba(221, 230, 239, 0.94); display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .status-card, .selection-summary, .note-card { border-radius: 16px; border: 1px solid rgba(218, 227, 236, 0.92); background: rgba(248, 251, 254, 0.92); padding: 12px 14px; } .canvas-toolbar-main { flex: 1 1 420px; display: flex; flex-direction: column; gap: 8px; } .status-card strong, .selection-summary strong, .note-card strong { display: block; font-size: 13px; margin-bottom: 6px; } .canvas-toolbar-title { display: flex; flex-direction: column; gap: 4px; } .status-card span, .selection-summary span, .note-card span { display: block; font-size: 12px; color: var(--text-sub); line-height: 1.65; } .canvas-toolbar-title h1 { margin: 0; font-size: 24px; font-weight: 700; letter-spacing: 0.3px; } .selection-summary strong { font-size: 14px; color: var(--text-main); } .canvas-toolbar-title span { font-size: 13px; line-height: 1.65; color: var(--text-sub); } .canvas-toolbar { padding: 16px 18px 14px; border-bottom: 1px solid rgba(221, 230, 239, 0.94); display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .canvas-toolbar-meta, .canvas-toolbar-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .canvas-toolbar-main { flex: 1 1 420px; display: flex; flex-direction: column; gap: 8px; } .canvas-toolbar-actions { justify-content: flex-end; flex: 0 1 760px; } .canvas-toolbar-title { display: flex; flex-direction: column; gap: 4px; } .canvas-toolbar-actions .el-input__inner, .canvas-toolbar-actions .el-button { border-radius: 10px; } .canvas-toolbar-title h1 { margin: 0; font-size: 24px; font-weight: 700; letter-spacing: 0.3px; } .canvas-meta { font-size: 12px; color: var(--text-sub); } .canvas-toolbar-title span { font-size: 13px; line-height: 1.65; color: var(--text-sub); } .canvas-card { flex: 1 1 auto; min-width: 0; min-height: 0; } .canvas-toolbar-meta, .canvas-toolbar-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .canvas-wrap { position: relative; flex: 1 1 auto; min-height: 0; background: linear-gradient(180deg, rgba(245, 249, 252, 0.95) 0%, rgba(251, 252, 254, 0.98) 100%); } .canvas-toolbar-actions { justify-content: flex-end; flex: 0 1 760px; } .canvas-stage { position: absolute; inset: 0; overflow: hidden; background: #f6f9fc; } .canvas-toolbar-actions .el-input__inner, .canvas-toolbar-actions .el-button { border-radius: 10px; } .canvas-host { position: absolute; inset: 0; background: #f6f9fc; } .canvas-meta { font-size: 12px; color: var(--text-sub); } .canvas-overlay-layer { position: absolute; inset: 0; pointer-events: none; z-index: 5; } .canvas-card { flex: 1 1 auto; min-width: 0; min-height: 0; } .canvas-loading-mask { position: absolute; inset: 0; z-index: 4; display: flex; align-items: center; justify-content: center; background: rgba(246, 249, 252, 0.82); backdrop-filter: blur(2px); } .canvas-wrap { position: relative; flex: 1 1 auto; min-height: 0; background: linear-gradient( 180deg, rgba(245, 249, 252, 0.95) 0%, rgba(251, 252, 254, 0.98) 100% ); } .canvas-loading-card { display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 220px; padding: 18px 22px; border-radius: 18px; background: rgba(255, 255, 255, 0.96); border: 1px solid rgba(55, 127, 212, 0.16); box-shadow: 0 18px 40px rgba(66, 94, 136, 0.12); color: var(--text-main); } .canvas-stage { position: absolute; inset: 0; overflow: hidden; background: #f6f9fc; } .canvas-loading-card strong { font-size: 18px; } .canvas-host { position: absolute; inset: 0; background: #f6f9fc; } .canvas-loading-card span { font-size: 13px; color: var(--text-sub); } .canvas-overlay-layer { position: absolute; inset: 0; pointer-events: none; z-index: 5; } .overlay-panel { position: absolute; top: 14px; bottom: 14px; width: 300px; display: flex; flex-direction: column; border-radius: 22px; border: 1px solid rgba(214, 224, 236, 0.96); background: #ffffff; box-shadow: 0 8px 18px rgba(31, 55, 82, 0.06); overflow: hidden; pointer-events: auto; contain: layout paint; } .canvas-loading-mask { position: absolute; inset: 0; z-index: 4; display: flex; align-items: center; justify-content: center; background: rgba(246, 249, 252, 0.82); backdrop-filter: blur(2px); } .canvas-loading-card { display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 220px; padding: 18px 22px; border-radius: 18px; background: rgba(255, 255, 255, 0.96); border: 1px solid rgba(55, 127, 212, 0.16); box-shadow: 0 18px 40px rgba(66, 94, 136, 0.12); color: var(--text-main); } .canvas-loading-card strong { font-size: 18px; } .canvas-loading-card span { font-size: 13px; color: var(--text-sub); } .overlay-panel { position: absolute; top: 14px; bottom: 14px; width: 300px; display: flex; flex-direction: column; border-radius: 22px; border: 1px solid rgba(214, 224, 236, 0.96); background: #ffffff; box-shadow: 0 8px 18px rgba(31, 55, 82, 0.06); overflow: hidden; pointer-events: auto; contain: layout paint; } .overlay-left { left: 14px; } .overlay-right { right: 14px; width: 340px; } .overlay-panel.collapsed { width: 68px; bottom: auto; } .overlay-panel.collapsed .panel-body { display: none; } .overlay-panel.collapsed .panel-head { align-items: flex-start; padding: 12px 10px; } .overlay-panel.collapsed .panel-head h2 { writing-mode: vertical-rl; text-orientation: mixed; font-size: 14px; line-height: 1; } .overlay-panel.collapsed .canvas-meta { display: none; } .panel-head-actions { display: flex; align-items: center; gap: 8px; } .panel-toggle { appearance: none; border: 1px solid rgba(190, 203, 217, 0.96); background: rgba(255, 255, 255, 0.96); color: var(--text-main); width: 30px; height: 30px; border-radius: 10px; cursor: pointer; font-size: 14px; font-weight: 700; line-height: 1; } .panel-toggle:hover { border-color: rgba(55, 127, 212, 0.4); color: var(--primary); } .prop-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .prop-grid .device-divider { margin: 0px 0 6px; } .prop-grid .span-2 { grid-column: span 2; } .field-stack { display: flex; flex-direction: column; gap: 6px; } .field-label { font-size: 12px; color: var(--text-sub); line-height: 1.4; } .field-required { color: #d85b52; font-weight: 700; } .field-help { font-size: 12px; color: var(--text-sub); line-height: 1.6; } .direction-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } .direction-chip { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; border: 1px solid rgba(55, 127, 212, 0.18); border-radius: 12px; background: #fff; color: var(--text-main); cursor: pointer; transition: all 0.16s ease; } .direction-chip:hover { border-color: rgba(55, 127, 212, 0.45); color: var(--primary); } .direction-chip.active { border-color: rgba(55, 127, 212, 0.85); background: rgba(85, 145, 227, 0.12); color: var(--primary); box-shadow: inset 0 0 0 1px rgba(85, 145, 227, 0.1); } .direction-arrow { font-size: 16px; font-weight: 700; line-height: 1; } .check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; } .json-box { font-family: Menlo, Monaco, Consolas, 'Liberation Mono', monospace; } .footer-note { font-size: 12px; color: var(--text-sub); line-height: 1.75; } @media (max-width: 1380px) { .overlay-left { left: 14px; width: 260px; } .overlay-right { right: 14px; width: 340px; width: 300px; } } @media (max-width: 980px) { .canvas-card { min-height: 0; } .overlay-panel.collapsed { width: 68px; bottom: auto; .canvas-wrap { min-height: 0; } .overlay-panel.collapsed .panel-body { display: none; .overlay-left, .overlay-right { width: min(280px, calc(100% - 28px)); } .overlay-panel.collapsed .panel-head { align-items: flex-start; padding: 12px 10px; } .overlay-panel.collapsed .panel-head h2 { writing-mode: vertical-rl; text-orientation: mixed; font-size: 14px; line-height: 1; } .overlay-panel.collapsed .canvas-meta { display: none; } .panel-head-actions { display: flex; align-items: center; gap: 8px; } .panel-toggle { appearance: none; border: 1px solid rgba(190, 203, 217, 0.96); background: rgba(255, 255, 255, 0.96); color: var(--text-main); width: 30px; height: 30px; border-radius: 10px; cursor: pointer; font-size: 14px; font-weight: 700; line-height: 1; } .panel-toggle:hover { border-color: rgba(55, 127, 212, 0.4); color: var(--primary); } .prop-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .prop-grid .span-2 { grid-column: span 2; } .field-stack { display: flex; flex-direction: column; gap: 6px; } .field-label { font-size: 12px; color: var(--text-sub); line-height: 1.4; } .field-required { color: #d85b52; font-weight: 700; } .field-help { font-size: 12px; color: var(--text-sub); line-height: 1.6; } .direction-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } .direction-chip { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; border: 1px solid rgba(55, 127, 212, 0.18); border-radius: 12px; background: #fff; color: var(--text-main); cursor: pointer; transition: all 0.16s ease; } .direction-chip:hover { border-color: rgba(55, 127, 212, 0.45); color: var(--primary); } .direction-chip.active { border-color: rgba(55, 127, 212, 0.85); background: rgba(85, 145, 227, 0.12); color: var(--primary); box-shadow: inset 0 0 0 1px rgba(85, 145, 227, 0.1); } .direction-arrow { font-size: 16px; font-weight: 700; line-height: 1; } .check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; } .json-box { font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace; } .footer-note { font-size: 12px; color: var(--text-sub); line-height: 1.75; } @media (max-width: 1380px) { .overlay-left { width: 260px; } .overlay-right { width: 300px; } } @media (max-width: 980px) { .canvas-card { min-height: 0; } .canvas-wrap { min-height: 0; } .overlay-left, .overlay-right { width: min(280px, calc(100% - 28px)); } } } </style> </head> <body> <div id="app" class="editor-shell" v-cloak> <section class="workspace"> </head> <body> <div id="app" class="editor-shell" v-cloak> <section class="workspace"> <main class="panel-card canvas-panel canvas-card"> <div class="canvas-toolbar"> <div class="canvas-toolbar-main"> <div class="canvas-toolbar-title"> <h1>WCS地图编辑器</h1> </div> <div class="canvas-toolbar-meta"> <span class="canvas-meta">楼层: {{ currentLev ? currentLev + 'F' : '--' }}</span> <span class="canvas-meta">缩放: {{ viewPercent }}%</span> <span class="canvas-meta">模式: {{ toolLabel(activeTool) }}</span> <span class="canvas-meta">选中: {{ selectedIds.length }}</span> <span class="canvas-meta">元素: {{ doc ? doc.elements.length : 0 }}</span> <span class="canvas-meta">画布: {{ Math.round(doc ? doc.canvasWidth : 0) }} x {{ Math.round(doc ? doc.canvasHeight : 0) }}</span> <span class="canvas-meta">渲染倍率: {{ formatNumber(pixiResolution) }}x</span> <span class="canvas-meta">FPS: {{ fpsText }}</span> </div> </div> <div class="canvas-toolbar-actions"> <el-select v-model="floorPickerLev" size="small" placeholder="选择楼层" @change="handleFloorChange" style="width: 120px;"> <el-option v-for="lev in levOptions" :key="'lev-' + lev" :label="lev + 'F'" :value="lev"></el-option> </el-select> <el-button size="small" plain @click="openBlankDialog">新建自由画布</el-button> <el-button size="small" plain @click="triggerImportExcel">导入 Excel</el-button> <el-button size="small" plain @click="triggerImportMap">导入地图</el-button> <el-button size="small" plain @click="exportMapPackage">导出地图</el-button> <el-button size="small" plain @click="loadCurrentFloor">重新读取</el-button> <el-button size="small" plain @click="fitContent">适配全图</el-button> <el-button size="small" plain @click="resetView">回到画布</el-button> <el-button size="small" @click="undo" :disabled="undoStack.length === 0">撤销</el-button> <el-button size="small" @click="redo" :disabled="redoStack.length === 0">重做</el-button> <el-button size="small" type="primary" plain :loading="savingAll" :disabled="dirtyDraftCount === 0 || saving" @click="saveAllDocs">保存全部楼层<span v-if="dirtyDraftCount > 0">({{ dirtyDraftCount }})</span></el-button> <el-button size="small" type="primary" :loading="saving" :disabled="savingAll" @click="saveDoc">保存当前楼层</el-button> </div> <div class="canvas-toolbar"> <div class="canvas-toolbar-main"> <div class="canvas-toolbar-title"> <h1>PixiJS 自由画布地图编辑器</h1> </div> <div class="canvas-toolbar-meta"> <span class="canvas-meta">楼层: {{ currentLev ? currentLev + 'F' : '--' }}</span> <span class="canvas-meta">缩放: {{ viewPercent }}%</span> <span class="canvas-meta">模式: {{ toolLabel(activeTool) }}</span> <span class="canvas-meta">选中: {{ selectedIds.length }}</span> <span class="canvas-meta">元素: {{ doc ? doc.elements.length : 0 }}</span> <span class="canvas-meta" >画布: {{ Math.round(doc ? doc.canvasWidth : 0) }} x {{ Math.round(doc ? doc.canvasHeight : 0) }}</span > <span class="canvas-meta">渲染倍率: {{ formatNumber(pixiResolution) }}x</span> <span class="canvas-meta">FPS: {{ fpsText }}</span> </div> </div> <div class="canvas-wrap"> <div class="canvas-stage" ref="canvasStage"> <div class="canvas-host" ref="canvasHost"></div> <div v-if="loadingFloor" class="canvas-loading-mask"> <div class="canvas-loading-card"> <strong>正在加载 {{ switchingFloorLev || floorPickerLev || currentLev || '--' }}F</strong> <span>画布和缓存正在切换,请稍候。</span> </div> </div> </div> <div class="canvas-overlay-layer"> <aside class="overlay-panel overlay-left" :class="{ collapsed: toolPanelCollapsed }"> <div class="panel-head"> <h2>工具面板</h2> <div class="panel-head-actions"> <span class="canvas-meta">{{ toolLabel(activeTool) }}</span> <button type="button" class="panel-toggle" @click="toggleToolPanel">{{ toolPanelCollapsed ? '>' : '<' }}</button> </div> </div> <div class="panel-body"> <div class="tool-section"> <div class="tool-section-label">交互</div> <div class="tool-grid"> <button v-for="tool in interactionTools" :key="tool.key" type="button" class="tool-card-btn" :class="{ active: activeTool === tool.key }" @click="setTool(tool.key)"> <strong>{{ tool.label }}</strong> <span>{{ tool.desc }}</span> </button> </div> </div> <div class="tool-section"> <div class="tool-section-label">绘制元素</div> <div class="tool-grid"> <button v-for="tool in drawTools" :key="tool.key" type="button" class="tool-card-btn" :class="{ active: activeTool === tool.key }" @click="setTool(tool.key)"> <strong>{{ tool.label }}</strong> <span>{{ tool.desc }}</span> </button> </div> </div> <div class="tool-section"> <div class="tool-section-label">编辑动作</div> <div class="action-list"> <el-button size="small" plain @click="copySelection" :disabled="selectedIds.length === 0">复制</el-button> <el-button size="small" plain @click="pasteClipboard" :disabled="clipboard.length === 0">粘贴</el-button> <el-button size="small" plain @click="duplicateSelection" :disabled="selectedIds.length === 0">复制偏移</el-button> <el-button size="small" plain @click="fitSelection" :disabled="selectedIds.length === 0">聚焦选中</el-button> <el-button size="small" type="danger" plain @click="deleteSelection" :disabled="selectedIds.length === 0">删除选中</el-button> </div> </div> <div class="status-stack"> <div class="status-card"> <strong>快捷键</strong> <span>`Delete` 删除,`Ctrl/Cmd + Z` 撤销,`Ctrl/Cmd + Shift + Z` / `Ctrl/Cmd + Y` 重做。</span> <span>`Ctrl/Cmd + C / V` 复制粘贴,按住空格可临时拖动画布,`Shift + 点击` 可增减单个选中。</span> <span>`阵列` 工具: 先选中一个货架 / 轨道模板,再拖一条水平或竖直线自动补齐一排;货架会按 `排-列` 规则继续编号。</span> </div> <div class="status-card"> <strong>当前状态</strong> <span>楼层: {{ currentLev ? currentLev + 'F' : '--' }}</span> <span>指针: {{ pointerStatus }}</span> <span v-if="arrayPreviewCount > 0">阵列预览: 将生成 {{ arrayPreviewCount }} 个</span> <span>未保存: {{ isDirty ? '是' : '否' }}</span> </div> </div> </div> </aside> <aside class="overlay-panel overlay-right" :class="{ collapsed: inspectorPanelCollapsed }"> <div class="panel-head"> <h2>属性面板</h2> <div class="panel-head-actions"> <span class="canvas-meta" v-if="singleSelectedElement">{{ singleSelectedElement.type }}</span> <span class="canvas-meta" v-else>{{ selectedIds.length > 1 ? '多选' : '画布' }}</span> <button type="button" class="panel-toggle" @click="toggleInspectorPanel">{{ inspectorPanelCollapsed ? '<' : '>' }}</button> </div> </div> <div class="panel-body"> <div class="selection-summary"> <strong v-if="singleSelectedElement">单元素编辑</strong> <strong v-else-if="selectedIds.length > 1">多选编辑</strong> <strong v-else>未选中元素</strong> <span v-if="singleSelectedElement">位置 {{ formatNumber(singleSelectedElement.x) }}, {{ formatNumber(singleSelectedElement.y) }} | 尺寸 {{ formatNumber(singleSelectedElement.width) }} x {{ formatNumber(singleSelectedElement.height) }}</span> <span v-else-if="selectedIds.length > 1">当前已选 {{ selectedIds.length }} 个元素,可整体移动、复制或删除。</span> <span v-else-if="activeTool === 'array'">先选中一个货架 / 轨道元素,再拖一条线生成阵列。</span> <span v-else>选择工具后点击元素,或切换框选工具框出一组元素。</span> </div> <div class="tool-section"> <div class="tool-section-label">画布设置</div> <div class="prop-grid"> <el-input v-model.trim="canvasForm.width" size="small" placeholder="画布宽度"></el-input> <el-input v-model.trim="canvasForm.height" size="small" placeholder="画布高度"></el-input> <el-button class="span-2" size="small" plain @click="applyCanvasSize">应用画布尺寸</el-button> </div> </div> <template v-if="singleSelectedElement"> <div class="tool-section"> <div class="tool-section-label">几何属性</div> <div class="prop-grid"> <el-input size="small" :value="singleSelectedElement.type" disabled></el-input> <el-input size="small" :value="singleSelectedElement.id" disabled></el-input> <el-input v-model.trim="geometryForm.x" size="small" placeholder="X"></el-input> <el-input v-model.trim="geometryForm.y" size="small" placeholder="Y"></el-input> <el-input v-model.trim="geometryForm.width" size="small" placeholder="宽度"></el-input> <el-input v-model.trim="geometryForm.height" size="small" placeholder="高度"></el-input> <el-button class="span-2" size="small" plain @click="applyGeometry">应用几何</el-button> </div> </div> <div v-if="singleSelectedElement.type === 'devp'" class="tool-section"> <div class="tool-section-label">输送站点配置</div> <div class="prop-grid"> <div class="field-stack"> <span class="field-label">站号</span> <el-input v-model.trim="devpForm.stationId" size="small" placeholder="请输入输送站点站号"></el-input> </div> <div class="field-stack"> <span class="field-label">PLC 编号</span> <el-input v-model.trim="devpForm.deviceNo" size="small" placeholder="请输入输送站点 PLC 编号"></el-input> </div> <div class="field-stack span-2"> <span class="field-label">方向</span> <div class="direction-grid"> <button v-for="item in devpDirectionOptions" :key="item.key" type="button" class="direction-chip" :class="{ active: isDevpDirectionActive(item.key) }" @click="toggleDevpDirection(item.key)"> <span class="direction-arrow">{{ item.arrow }}</span> <span>{{ item.label }}</span> </button> </div> <div class="field-help">点击箭头切换方向,可同时选择多个方向。</div> </div> <div class="field-stack span-2"> <span class="field-label">站点类型</span> <div class="check-grid"> <el-checkbox v-model="devpForm.isBarcodeStation">条码站</el-checkbox> <el-checkbox v-model="devpForm.isInStation">入站点</el-checkbox> <el-checkbox v-model="devpForm.isOutStation">出站点</el-checkbox> <el-checkbox v-model="devpForm.runBlockReassign">堵塞重分配</el-checkbox> <el-checkbox v-model="devpForm.isOutOrder">出库排序</el-checkbox> <el-checkbox v-model="devpForm.isLiftTransfer">顶升移栽</el-checkbox> </div> </div> <div class="field-stack"> <span class="field-label">条码索引<span v-if="devpRequiresBarcodeIndex" class="field-required"> 必填</span></span> <el-input v-model.trim="devpForm.barcodeIdx" size="small" placeholder="条码站时必填,例如 1"></el-input> </div> <div class="field-stack"> <span class="field-label">条码站站号<span v-if="devpRequiresBarcodeLink" class="field-required"> 必填</span></span> <el-input v-model.trim="devpForm.barcodeStation" size="small" placeholder="入站点时必填,填写条码站站号"></el-input> </div> <div class="field-stack"> <span class="field-label">条码站 PLC 编号<span v-if="devpRequiresBarcodeLink" class="field-required"> 必填</span></span> <el-input v-model.trim="devpForm.barcodeStationDeviceNo" size="small" placeholder="入站点时必填,填写条码站 PLC 编号"></el-input> </div> <div class="field-stack"> <span class="field-label">退回站站号<span v-if="devpRequiresBackStation" class="field-required"> 必填</span></span> <el-input v-model.trim="devpForm.backStation" size="small" placeholder="条码站时必填,填写退回站站号"></el-input> </div> <div class="field-stack"> <span class="field-label">退回站 PLC 编号<span v-if="devpRequiresBackStation" class="field-required"> 必填</span></span> <el-input v-model.trim="devpForm.backStationDeviceNo" size="small" placeholder="条码站时必填,填写退回站 PLC 编号"></el-input> </div> <div class="footer-note span-2"> 勾选“入站点”后,必须填写条码站站号和条码站 PLC 编号。 勾选“条码站”后,必须填写条码索引、退回站站号和退回站 PLC 编号。 </div> <el-button class="span-2" size="small" type="primary" plain @click="applyDevpForm">应用输送线配置</el-button> </div> </div> <div v-if="singleSelectedDeviceElement" class="tool-section"> <div class="tool-section-label">{{ getDeviceConfigLabel(singleSelectedDeviceElement.type) }}</div> <div class="prop-grid"> <el-input size="small" :value="getDeviceConfigKeyLabel(singleSelectedDeviceElement.type, deviceForm.valueKey)" disabled></el-input> <el-input v-model.trim="deviceForm.deviceNo" size="small" placeholder="设备编号"></el-input> <el-button class="span-2" size="small" type="primary" plain @click="applyDeviceForm">应用设备参数</el-button> </div> <div class="footer-note" style="padding-top: 8px;"> 这里只改设备编号相关键,原始 JSON 里的其他字段会保留;下面仍可直接查看或手工修改 JSON。 </div> </div> <div class="tool-section"> <div class="tool-section-label">{{ singleSelectedElement.type === 'devp' ? '原始 JSON 预览' : (singleSelectedDeviceElement ? '原始 JSON 预览 / 手工编辑' : '值 / JSON 编辑') }}</div> <el-input class="json-box" type="textarea" :rows="8" v-model="valueEditorText" :readonly="singleSelectedElement.type === 'devp'"> </el-input> <el-button v-if="singleSelectedElement.type !== 'devp'" size="small" type="primary" plain @click="applyRawValue" >应用值</el-button> </div> </template> <div v-if="selectedShelfElements.length > 0" class="tool-section"> <div class="tool-section-label">货架自动填充</div> <div class="prop-grid"> <el-input v-model.trim="shelfFillForm.startValue" size="small" placeholder="起始值,例如 12-1"></el-input> <el-input size="small" :value="'已选货架 ' + selectedShelfElements.length + ' 个'" disabled></el-input> <el-select v-model="shelfFillForm.rowStep" size="small" placeholder="排方向"> <el-option label="上到下递减" value="desc"></el-option> <el-option label="上到下递增" value="asc"></el-option> </el-select> <el-select v-model="shelfFillForm.colStep" size="small" placeholder="列方向"> <el-option label="左到右递增" value="asc"></el-option> <el-option label="左到右递减" value="desc"></el-option> </el-select> <el-button class="span-2" size="small" type="primary" plain @click="applyShelfAutoFill">按排列填充货架值</el-button> </div> <div class="footer-note" style="padding-top: 8px;"> 会按选中货架的实际空间排列分组填充。默认规则是上到下排号递减、左到右列号递增。 </div> </div> </div> </aside> </div> <div class="canvas-toolbar-actions"> <el-select v-model="floorPickerLev" size="small" placeholder="选择楼层" @change="handleFloorChange" style="width: 120px" > <el-option v-for="lev in levOptions" :key="'lev-' + lev" :label="lev + 'F'" :value="lev" ></el-option> </el-select> <el-button size="small" plain @click="openBlankDialog">新建自由画布</el-button> <!-- <el-button size="small" plain @click="triggerImportExcel">导入 Excel</el-button> --> <el-button size="small" plain @click="triggerImportMap">导入地图</el-button> <el-button size="small" plain @click="exportMapPackage">导出地图</el-button> <el-button size="small" plain @click="loadCurrentFloor">重新读取</el-button> <el-button size="small" plain @click="fitContent">适配全图</el-button> <el-button size="small" plain @click="resetView">回到画布</el-button> <el-button size="small" @click="undo" :disabled="undoStack.length === 0" >撤销</el-button > <el-button size="small" @click="redo" :disabled="redoStack.length === 0" >重做</el-button > <el-button size="small" type="primary" plain :loading="savingAll" :disabled="dirtyDraftCount === 0 || saving" @click="saveAllDocs" >保存全部楼层<span v-if="dirtyDraftCount > 0" >({{ dirtyDraftCount }})</span ></el-button > <el-button size="small" type="primary" :loading="saving" :disabled="savingAll" @click="saveDoc" >保存当前楼层</el-button > </div> </div> <div class="canvas-wrap"> <div class="canvas-stage" ref="canvasStage"> <div class="canvas-host" ref="canvasHost"></div> <div v-if="loadingFloor" class="canvas-loading-mask"> <div class="canvas-loading-card"> <strong >正在加载 {{ switchingFloorLev || floorPickerLev || currentLev || '--' }}F</strong > <span>画布和缓存正在切换,请稍候。</span> </div> </div> </div> <div class="canvas-overlay-layer"> <aside class="overlay-panel overlay-left" :class="{ collapsed: toolPanelCollapsed }"> <div class="panel-head"> <h2>工具面板</h2> <div class="panel-head-actions"> <span class="canvas-meta">{{ toolLabel(activeTool) }}</span> <button type="button" class="panel-toggle" @click="toggleToolPanel"> {{ toolPanelCollapsed ? '>' : '<' }} </button> </div> </div> <div class="panel-body"> <div class="tool-section"> <div class="tool-section-label">交互</div> <div class="tool-grid"> <button v-for="tool in interactionTools" :key="tool.key" type="button" class="tool-card-btn" :class="{ active: activeTool === tool.key }" @click="setTool(tool.key)" > <strong>{{ tool.label }}</strong> <span>{{ tool.desc }}</span> </button> </div> </div> <div class="tool-section"> <div class="tool-section-label">绘制元素</div> <div class="tool-grid"> <template v-for="tool in drawTools"> <el-popover class="tool-el-popover" v-if="tool.key === 'annulus'" :key="tool.key" placement="right" title="请选择绘制的形状" width="520" trigger="click" > <el-radio-group v-model="annulusShape" size="mini"> <el-radio-button label="rect">圆角矩形</el-radio-button> <el-radio-button label="L1">圆角L型</el-radio-button> <el-radio-button label="L2">圆角L型旋转90°</el-radio-button> <el-radio-button label="L3">圆角L型旋转180°</el-radio-button> <el-radio-button label="L4">圆角L型旋转270°</el-radio-button> </el-radio-group> <button slot="reference" type="button" class="tool-card-btn" :class="{ active: activeTool === tool.key }" @click="setTool(tool.key)" > <strong>{{ tool.label }}</strong> <span>{{ tool.desc }}</span> </button> </el-popover> <button v-else :key="tool.key" type="button" class="tool-card-btn" :class="{ active: activeTool === tool.key }" @click="setTool(tool.key)" > <strong>{{ tool.label }}</strong> <span>{{ tool.desc }}</span> </button> </template> </div> </div> <div class="tool-section"> <div class="tool-section-label">编辑动作</div> <div class="action-list"> <el-button size="small" plain @click="copySelection" :disabled="selectedIds.length === 0" >复制</el-button > <el-button size="small" plain @click="pasteClipboard" :disabled="clipboard.length === 0" >粘贴</el-button > <el-button size="small" plain @click="duplicateSelection" :disabled="selectedIds.length === 0" >复制偏移</el-button > <el-button size="small" plain @click="fitSelection" :disabled="selectedIds.length === 0" >聚焦选中</el-button > <el-button size="small" type="danger" plain @click="deleteSelection" :disabled="selectedIds.length === 0" >删除选中</el-button > </div> </div> <div class="status-stack"> <div class="status-card"> <strong>快捷键</strong> <span >`Delete` 删除,`Ctrl/Cmd + Z` 撤销,`Ctrl/Cmd + Shift + Z` / `Ctrl/Cmd + Y` 重做。</span > <span >`Ctrl/Cmd + C / V` 复制粘贴,按住空格可临时拖动画布,`Shift + 点击` 可增减单个选中。</span > <span >`阵列` 工具: 先选中一个货架 / 维修站台模板,再拖一条水平或竖直线自动补齐一排;货架会按 `排-列` 规则继续编号。</span > </div> <div class="status-card"> <strong>当前状态</strong> <span>楼层: {{ currentLev ? currentLev + 'F' : '--' }}</span> <span>指针: {{ pointerStatus }}</span> <span v-if="arrayPreviewCount > 0" >阵列预览: 将生成 {{ arrayPreviewCount }} 个</span > <span>未保存: {{ isDirty ? '是' : '否' }}</span> </div> </div> </div> </aside> <aside class="overlay-panel overlay-right" :class="{ collapsed: inspectorPanelCollapsed }" > <div class="panel-head"> <h2>属性面板</h2> <div class="panel-head-actions"> <span class="canvas-meta" v-if="singleSelectedElement" >{{ singleSelectedElement.type }}</span > <span class="canvas-meta" v-else >{{ selectedIds.length > 1 ? '多选' : '画布' }}</span > <button type="button" class="panel-toggle" @click="toggleInspectorPanel"> {{ inspectorPanelCollapsed ? '<' : '>' }} </button> </div> </div> <div class="panel-body"> <div class="selection-summary"> <strong v-if="singleSelectedElement">单元素编辑</strong> <strong v-else-if="selectedIds.length > 1">多选编辑</strong> <strong v-else>未选中元素</strong> <span v-if="singleSelectedElement" >位置 {{ formatNumber(singleSelectedElement.x) }}, {{ formatNumber(singleSelectedElement.y) }} | 尺寸 {{ formatNumber(singleSelectedElement.width) }} x {{ formatNumber(singleSelectedElement.height) }}</span > <span v-else-if="selectedIds.length > 1" >当前已选 {{ selectedIds.length }} 个元素,可整体移动、复制或删除。</span > <span v-else-if="activeTool === 'array'" >先选中一个货架 / 维修站台元素,再拖一条线生成阵列。</span > <span v-else>选择工具后点击元素,或切换框选工具框出一组元素。</span> </div> <div class="tool-section"> <div class="tool-section-label">画布设置</div> <div class="prop-grid"> <el-input v-model.trim="canvasForm.width" size="small" placeholder="画布宽度" ></el-input> <el-input v-model.trim="canvasForm.height" size="small" placeholder="画布高度" ></el-input> <el-button class="span-2" size="small" plain @click="applyCanvasSize" >应用画布尺寸</el-button > </div> </div> <template v-if="singleSelectedElement"> <div class="tool-section"> <div class="tool-section-label">几何属性</div> <div class="prop-grid"> <el-input size="small" :value="singleSelectedElement.type" disabled ></el-input> <el-input size="small" :value="singleSelectedElement.id" disabled ></el-input> <el-input v-model.trim="geometryForm.x" size="small" placeholder="X" ></el-input> <el-input v-model.trim="geometryForm.y" size="small" placeholder="Y" ></el-input> <el-input v-model.trim="geometryForm.width" size="small" placeholder="宽度" ></el-input> <el-input v-model.trim="geometryForm.height" size="small" placeholder="高度" ></el-input> <el-button class="span-2" size="small" plain @click="applyGeometry" >应用几何</el-button > </div> </div> <div v-if="singleSelectedElement.type === 'devp'" class="tool-section"> <div class="tool-section-label">输送站点配置</div> <div class="prop-grid"> <div class="field-stack"> <span class="field-label">站号</span> <el-input v-model.trim="devpForm.stationId" size="small" placeholder="请输入输送站点站号" ></el-input> </div> <div class="field-stack"> <span class="field-label">PLC 编号</span> <el-input v-model.trim="devpForm.deviceNo" size="small" placeholder="请输入输送站点 PLC 编号" ></el-input> </div> <div class="field-stack span-2"> <span class="field-label">方向</span> <div class="direction-grid"> <button v-for="item in devpDirectionOptions" :key="item.key" type="button" class="direction-chip" :class="{ active: isDevpDirectionActive(item.key) }" @click="toggleDevpDirection(item.key)" > <span class="direction-arrow">{{ item.arrow }}</span> <span>{{ item.label }}</span> </button> </div> <div class="field-help">点击箭头切换方向,可同时选择多个方向。</div> </div> <div class="field-stack span-2"> <span class="field-label">站点类型</span> <div class="check-grid"> <el-checkbox v-model="devpForm.isBarcodeStation">条码站</el-checkbox> <el-checkbox v-model="devpForm.isInStation">入站点</el-checkbox> <el-checkbox v-model="devpForm.isOutStation">出站点</el-checkbox> <el-checkbox v-model="devpForm.runBlockReassign" >堵塞重分配</el-checkbox > <el-checkbox v-model="devpForm.isOutOrder">出库排序</el-checkbox> <el-checkbox v-model="devpForm.isLiftTransfer">顶升移栽</el-checkbox> </div> </div> <div class="field-stack"> <span class="field-label" >条码索引<span v-if="devpRequiresBarcodeIndex" class="field-required"> 必填</span ></span > <el-input v-model.trim="devpForm.barcodeIdx" size="small" placeholder="条码站时必填,例如 1" ></el-input> </div> <div class="field-stack"> <span class="field-label" >条码站站号<span v-if="devpRequiresBarcodeLink" class="field-required"> 必填</span ></span > <el-input v-model.trim="devpForm.barcodeStation" size="small" placeholder="入站点时必填,填写条码站站号" ></el-input> </div> <div class="field-stack"> <span class="field-label" >条码站 PLC 编号<span v-if="devpRequiresBarcodeLink" class="field-required" > 必填</span ></span > <el-input v-model.trim="devpForm.barcodeStationDeviceNo" size="small" placeholder="入站点时必填,填写条码站 PLC 编号" ></el-input> </div> <div class="field-stack"> <span class="field-label" >退回站站号<span v-if="devpRequiresBackStation" class="field-required"> 必填</span ></span > <el-input v-model.trim="devpForm.backStation" size="small" placeholder="条码站时必填,填写退回站站号" ></el-input> </div> <div class="field-stack"> <span class="field-label" >退回站 PLC 编号<span v-if="devpRequiresBackStation" class="field-required" > 必填</span ></span > <el-input v-model.trim="devpForm.backStationDeviceNo" size="small" placeholder="条码站时必填,填写退回站 PLC 编号" ></el-input> </div> <div class="footer-note span-2"> 勾选“入站点”后,必须填写条码站站号和条码站 PLC 编号。 勾选“条码站”后,必须填写条码索引、退回站站号和退回站 PLC 编号。 </div> <el-button class="span-2" size="small" type="primary" plain @click="applyDevpForm" >应用输送线配置</el-button > </div> </div> <div v-if="singleSelectedDeviceElement" class="tool-section"> <div class="tool-section-label"> {{ getDeviceConfigLabel(singleSelectedDeviceElement.type) }} </div> <div class="prop-grid"> <el-input size="small" value="轨道ID" disabled></el-input> <el-input v-model.trim="deviceForm.trackId" size="small" placeholder="轨道ID(默认递增,从 1 开始)" ></el-input> <el-input size="small" value="条码起始值" disabled></el-input> <el-input v-model.trim="deviceForm.barCodeStart" size="small" placeholder="轨道条码起始值,默认 0" ></el-input> <el-input size="small" value="条码结束值" disabled></el-input> <el-input v-model.trim="deviceForm.barCodeEnd" size="small" placeholder="轨道条码结束值,默认 100000" ></el-input> <div class="tool-section-label-sub span-2" v-if="deviceForm.deviceList.length > 0" > 设备列表 </div> <template v-for="(item, index) in deviceForm.deviceList"> <el-input size="small" :value="getDeviceConfigKeyLabel(singleSelectedDeviceElement.type, item.valueKey)" disabled ></el-input> <el-input v-model.trim="item.deviceNo" size="small" placeholder="设备编号" ></el-input> <el-input size="small" value="起始位置" disabled></el-input> <div style="display: flex; align-items: center; gap: 10px; margin-left: 12px" > <el-slider v-model="item.progress" :max="100" :min="0" show-tooltip style="flex: 1" ></el-slider> <span style=" font-size: 12px; color: var(--text-sub); width: 30px; text-align: right; " >{{ item.progress }}%</span > </div> <el-input size="small" value="设备长(沿轨道)" disabled></el-input> <el-input v-model.trim="item.deviceLength" size="small" :placeholder="getDeviceLengthPlaceholder()" ></el-input> <el-input size="small" value="设备宽(垂直轨道)" disabled></el-input> <el-input v-model.trim="item.deviceWidth" size="small" :placeholder="getDeviceWidthPlaceholder()" ></el-input> <!-- <div class="field-help span-2">默认(自动): 长 {{ getAutoTrackDeviceLengthValue() }} | 宽 {{ getAutoTrackDeviceWidthValue() }}</div> --> <el-divider class="device-divider span-2" v-if="index < deviceForm.deviceList.length - 1" ></el-divider> </template> <el-button size="small" type="primary" plain @click="addDeviceForm" >添加设备</el-button > <el-button size="small" type="primary" plain @click="applyDeviceForm" >应用设备参数</el-button > </div> <div class="footer-note" style="padding-top: 8px"> 这里只改设备编号相关键,原始 JSON 里的其他字段会保留;下面仍可直接查看或手工修改 JSON。 </div> </div> <div class="tool-section"> <div class="tool-section-label"> {{ singleSelectedElement.type === 'devp' ? '原始 JSON 预览' : (singleSelectedDeviceElement ? '原始 JSON 预览 / 手工编辑' : '值 / JSON 编辑') }} </div> <el-input class="json-box" type="textarea" :rows="8" v-model="valueEditorText" :readonly="singleSelectedElement.type === 'devp'" > </el-input> <el-button v-if="singleSelectedElement.type !== 'devp'" size="small" type="primary" plain @click="applyRawValue" >应用值</el-button > </div> </template> <div v-if="selectedShelfElements.length > 0" class="tool-section"> <div class="tool-section-label">货架自动填充</div> <div class="prop-grid"> <el-input v-model.trim="shelfFillForm.startValue" size="small" placeholder="起始值,例如 12-1" ></el-input> <el-input size="small" :value="'已选货架 ' + selectedShelfElements.length + ' 个'" disabled ></el-input> <el-select v-model="shelfFillForm.rowStep" size="small" placeholder="排方向"> <el-option label="上到下递减" value="desc"></el-option> <el-option label="上到下递增" value="asc"></el-option> </el-select> <el-select v-model="shelfFillForm.colStep" size="small" placeholder="列方向"> <el-option label="左到右递增" value="asc"></el-option> <el-option label="左到右递减" value="desc"></el-option> </el-select> <el-button class="span-2" size="small" type="primary" plain @click="applyShelfAutoFill" >按排列填充货架值</el-button > </div> <div class="footer-note" style="padding-top: 8px"> 会按选中货架的实际空间排列分组填充。默认规则是上到下排号递减、左到右列号递增。 </div> </div> </div> </aside> </div> </div> </main> </section> </section> <el-dialog title="新建自由画布" :visible.sync="blankDialogVisible" width="420px" class="dialog-panel" append-to-body> <el-dialog title="新建自由画布" :visible.sync="blankDialogVisible" width="420px" class="dialog-panel" append-to-body > <el-form label-width="90px" size="small"> <el-form-item label="楼层"> <el-input v-model.trim="blankForm.lev"></el-input> </el-form-item> <el-form-item label="宽度"> <el-input v-model.trim="blankForm.width"></el-input> </el-form-item> <el-form-item label="高度"> <el-input v-model.trim="blankForm.height"></el-input> </el-form-item> <el-form-item label="楼层"> <el-input v-model.trim="blankForm.lev"></el-input> </el-form-item> <el-form-item label="宽度"> <el-input v-model.trim="blankForm.width"></el-input> </el-form-item> <el-form-item label="高度"> <el-input v-model.trim="blankForm.height"></el-input> </el-form-item> </el-form> <div slot="footer"> <el-button @click="blankDialogVisible = false">取消</el-button> <el-button type="primary" @click="createBlankMap">创建</el-button> <el-button @click="blankDialogVisible = false">取消</el-button> <el-button type="primary" @click="createBlankMap">创建</el-button> </div> </el-dialog> </el-dialog> <input ref="importInput" type="file" style="display:none;" @change="handleImportExcel"> <input ref="mapImportInput" type="file" accept=".json,application/json" style="display:none;" @change="handleImportMap"> </div> <input ref="importInput" type="file" style="display: none" @change="handleImportExcel" /> <input ref="mapImportInput" type="file" accept=".json,application/json" style="display: none" @change="handleImportMap" /> </div> <script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script> <script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script> <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script> <script type="text/javascript" src="../../static/vue/element/element.js"></script> <script type="text/javascript" src="../../static/js/pixi-legacy.min.js"></script> <script type="text/javascript" src="../../static/js/basMap/editor.js?v=20260321e"></script> </body> <script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script> <script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script> <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script> <script type="text/javascript" src="../../static/vue/element/element.js"></script> <script type="text/javascript" src="../../static/js/pixi-legacy.min.js"></script> <script type="text/javascript" src="../../static/js/basMap/mapTrackGeometry.js?v=20260406b" ></script> <script type="text/javascript" src="../../static/js/basMap/editor.js?v=20260321e"></script> </body> </html> src/main/webapp/views/watch/console.html
@@ -553,6 +553,7 @@ <script src="../../components/WatchDualCrnCard.js"></script> <script src="../../components/DevpCard.js"></script> <script src="../../components/WatchRgvCard.js"></script> <script src="../../static/js/basMap/mapTrackGeometry.js?v=20260406a"></script> <script src="../../components/MapCanvas.js?v=20260320_crn_size_fix1"></script> <script> let ws; src/main/webapp/views/watch/fakeTrace.html
@@ -517,6 +517,7 @@ </div> </div> <script src="../../static/js/basMap/mapTrackGeometry.js?v=20260406a"></script> <script src="../../components/MapCanvas.js?v=20260320_crn_size_fix1"></script> <script> var fakeTraceWs = null; src/main/webapp/views/watch/stationTrace.html
@@ -567,6 +567,7 @@ </div> </div> <script src="../../static/js/basMap/mapTrackGeometry.js?v=20260406a"></script> <script src="../../components/MapCanvas.js?v=20260319_station_trace_v1"></script> <script> var stationTraceWs = null;