diff --git a/javaTest/README.md b/javaTest/README.md index 8f50204..4020e82 100644 --- a/javaTest/README.md +++ b/javaTest/README.md @@ -96,6 +96,28 @@ mvn compile exec:java \ > 历史表不含独立的仿真起始时刻车辆位置/状态快照,因此车辆 `1/2/3` 分别从完整地图中的 > 固定站点 `010701/010702/010703` 初始化;历史 `agv_id` 仅用于对照,不参与初始位置或选车约束。 +## 打开调度路径审计页面 + +审计页面调用上述同一套 Java 调度、规划、交通裁决和仿真代码,浏览器只负责显示服务端 +返回的选车、`plannedPath` 和逐 tick 事件,不在 JavaScript 中复制算法。启动命令: + +```powershell +mvn compile exec:java "-Dexec.mainClass=com.ft546.wcs.shadow.history.visualizer.HistoricalDispatchVisualizerApplication" "-Dexec.args=18084" +``` + +浏览器访问 `http://127.0.0.1:18084`。在“A → B 单任务”中填写完整地图内的起点和终点, +选择任务类型、优先级、加速倍率及可选故障预设,然后运行调度。页面会显示中心选车、车辆到 +起点的接近路径、调度中心重新规划的 A → B 路径、逐节点移动、HOLD 和 PLC 重试事件。 +时间线可逐事件前进、后退或自动播放;灰色虚线为接近路径,绿色为 `plannedPath`,蓝色为 +已经通过的边,橙色点线仅表示历史参考路径。 + +“240 条历史任务”会重新执行全部历史负载并列出完成、等待、失败、重新选车和路径变化; +“安全审计”集中显示节点双占、资源超容量、对向换边、死锁、归档完整性和 WMS 回调幂等。 +导出的 JSON 是本次内存仿真的页面证据。 + +服务严格绑定 `127.0.0.1`,只读取打包的历史与地图资源,不接收任意文件路径,不连接数据库、 +真实 PLC 或 WMS。按 `Ctrl+C` 停止服务。该页面用于离线上线前检查,不能替代现场 HIL/ACTIVE 验收。 + ## 运行控制台模拟 ```bash diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServer.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServer.java index 9b26cee..9b86b83 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServer.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServer.java @@ -110,6 +110,22 @@ public final class HistoricalDispatchVisualizerServer { private void route(HttpExchange exchange) throws IOException { String path = exchange.getRequestURI().getPath(); String method = exchange.getRequestMethod(); + if ("/".equals(path) || "/index.html".equals(path)) { + requireMethod(method, "GET"); + writeStatic(exchange, "visualizer/index.html", "text/html; charset=utf-8"); + return; + } + if ("/assets/app.css".equals(path)) { + requireMethod(method, "GET"); + writeStatic(exchange, "visualizer/assets/app.css", "text/css; charset=utf-8"); + return; + } + if ("/assets/app.js".equals(path)) { + requireMethod(method, "GET"); + writeStatic(exchange, "visualizer/assets/app.js", + "application/javascript; charset=utf-8"); + return; + } if ("/api/health".equals(path)) { requireMethod(method, "GET"); Map health = new LinkedHashMap(); @@ -146,6 +162,40 @@ public final class HistoricalDispatchVisualizerServer { throw new VisualizerHttpException(404, "NOT_FOUND", "请求的审计资源不存在"); } + /** + * 从打包后的 classpath 返回审计台资源。调用方只传入固定白名单,浏览器无法借此读取本机文件。 + */ + private void writeStatic(HttpExchange exchange, String resourcePath, String contentType) + throws IOException { + byte[] body; + try (InputStream inputStream = HistoricalDispatchVisualizerServer.class + .getClassLoader().getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new VisualizerHttpException(500, "STATIC_RESOURCE_MISSING", + "审计页面资源未打包: " + resourcePath); + } + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[4096]; + int read; + while ((read = inputStream.read(buffer)) >= 0) { + output.write(buffer, 0, read); + } + body = output.toByteArray(); + } + } + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.getResponseHeaders().set("Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self'; " + + "img-src 'self' data:; connect-src 'self'; object-src 'none'; " + + "base-uri 'none'; frame-ancestors 'none'"); + exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(body); + } + } + private Map runSimulation(SimulationCall call) { if (!simulationBusy.compareAndSet(false, true)) { throw new VisualizerHttpException(409, "SIMULATION_BUSY", "已有仿真正在运行"); diff --git a/javaTest/src/main/resources/visualizer/assets/app.css b/javaTest/src/main/resources/visualizer/assets/app.css new file mode 100644 index 0000000..e8a4401 --- /dev/null +++ b/javaTest/src/main/resources/visualizer/assets/app.css @@ -0,0 +1,409 @@ +:root { + --color-bg: #edf1f0; + --color-surface: #ffffff; + --color-surface-muted: #f5f7f6; + --color-ink: #16211f; + --color-muted: #65736f; + --color-line: #d4ddda; + --color-line-strong: #aebbb7; + --color-green: #13795b; + --color-green-soft: #e2f2eb; + --color-blue: #147d9d; + --color-blue-soft: #dff1f5; + --color-amber: #b66a0a; + --color-amber-soft: #fff0d8; + --color-red: #bd3f3f; + --color-red-soft: #fae5e3; + --color-approach-path: #727b7f; + --color-service-path: #16805e; + --color-traversed-path: #087fa2; + --color-historical-path: #c67b20; + --font-ui: Bahnschrift, "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; + --font-mono: "Cascadia Mono", Consolas, monospace; + --radius: 6px; +} + +* { box-sizing: border-box; } + +html { min-width: 320px; background: var(--color-bg); } + +body { + margin: 0; + color: var(--color-ink); + background: var(--color-bg); + font-family: var(--font-ui); + font-size: 14px; + letter-spacing: 0; +} + +button, input, select { font: inherit; letter-spacing: 0; } +button, select, input[type="range"], input[type="checkbox"] { cursor: pointer; } +button:focus-visible, input:focus-visible, select:focus-visible { + outline: 3px solid rgba(20, 125, 157, .22); + outline-offset: 1px; +} + +[hidden] { display: none !important; } + +.app-shell { min-height: 100vh; } + +.app-header { + min-height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 10px 24px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-line); +} + +.brand-block, .header-status, .button-row, .playback-buttons, .route-tools, +.history-actions, .timeline-controls, .table-toolbar, .inline-field, .toggle-field { + display: flex; + align-items: center; +} + +.brand-block { gap: 12px; min-width: 0; } +.brand-block > div { min-width: 0; } +.brand-mark { width: 34px; height: 34px; position: relative; flex: 0 0 34px; } +.brand-mark::before, .brand-mark::after, .brand-mark i { + content: ""; + position: absolute; + background: var(--color-green); +} +.brand-mark::before { width: 8px; height: 8px; border-radius: 50%; left: 1px; top: 13px; } +.brand-mark::after { width: 8px; height: 8px; border-radius: 50%; right: 1px; top: 2px; } +.brand-mark i:nth-child(1) { left: 8px; top: 16px; width: 20px; height: 2px; transform: rotate(-30deg); transform-origin: left; } +.brand-mark i:nth-child(2) { right: 1px; bottom: 3px; width: 8px; height: 8px; border-radius: 50%; background: var(--color-blue); } +.brand-mark i:nth-child(3) { right: 7px; top: 9px; width: 2px; height: 19px; transform: rotate(4deg); background: var(--color-blue); } + +h1, h2, h3, p { margin: 0; } +h1 { font-size: 20px; line-height: 1.2; font-weight: 720; } +h2 { font-size: 16px; line-height: 1.25; font-weight: 700; } +h3 { font-size: 14px; line-height: 1.3; } +.brand-block p { margin-top: 3px; color: var(--color-muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.header-status { gap: 8px; flex-wrap: wrap; justify-content: flex-end; } +.meta-value { color: var(--color-muted); font-family: var(--font-mono); font-size: 12px; } + +.status-chip { + min-height: 26px; + display: inline-flex; + align-items: center; + padding: 3px 9px; + border: 1px solid transparent; + border-radius: 999px; + font-size: 12px; + font-weight: 650; + white-space: nowrap; +} +.status-pending, .status-neutral { color: #4c5a56; background: #edf1f0; border-color: #d8dfdd; } +.status-pass { color: #0e674c; background: var(--color-green-soft); border-color: #b8dfd0; } +.status-hold { color: #8b5108; background: var(--color-amber-soft); border-color: #efd09d; } +.status-fail { color: #9e3030; background: var(--color-red-soft); border-color: #edb9b5; } + +.mode-tabs { + height: 44px; + display: flex; + align-items: end; + gap: 4px; + padding: 0 24px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-line); +} +.mode-tab { + height: 43px; + padding: 0 16px; + color: var(--color-muted); + background: transparent; + border: 0; + border-bottom: 3px solid transparent; + font-weight: 650; +} +.mode-tab:hover { color: var(--color-ink); background: var(--color-surface-muted); } +.mode-tab.is-active { color: var(--color-green); border-bottom-color: var(--color-green); } + +.control-band, .history-band { + padding: 12px 24px; + background: var(--color-surface-muted); + border-bottom: 1px solid var(--color-line); +} +.point-form { + display: grid; + grid-template-columns: minmax(150px, 1.3fr) 36px minmax(150px, 1.3fr) minmax(100px, .7fr) 82px 90px minmax(160px, 1fr) auto; + align-items: end; + gap: 8px; +} +.field { min-width: 0; display: grid; gap: 5px; } +.field > span, .inline-field > span, .toggle-field > span { + color: var(--color-muted); + font-size: 11px; + font-weight: 650; +} +.field input, .field select, .search-field input, .table-toolbar select, .inline-field select { + width: 100%; + height: 36px; + padding: 0 10px; + color: var(--color-ink); + background: var(--color-surface); + border: 1px solid var(--color-line-strong); + border-radius: var(--radius); +} +.field input:hover, .field select:hover, .search-field input:hover, .table-toolbar select:hover { border-color: #83938e; } + +.primary-button, .secondary-button, .icon-button { + height: 36px; + border-radius: var(--radius); + font-weight: 700; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; +} +.primary-button { + padding: 0 15px; + color: #fff; + background: var(--color-green); + border: 1px solid var(--color-green); + white-space: nowrap; +} +.primary-button:hover { background: #0e684d; border-color: #0e684d; } +.secondary-button { + padding: 0 13px; + color: var(--color-ink); + background: var(--color-surface); + border: 1px solid var(--color-line-strong); +} +.secondary-button:hover { border-color: #72817c; } +.primary-button:disabled, .secondary-button:disabled { opacity: .48; cursor: not-allowed; } +.icon-button { + width: 36px; + padding: 0; + color: var(--color-ink); + background: var(--color-surface); + border: 1px solid var(--color-line-strong); + font-family: var(--font-mono); +} +.icon-button:hover { color: var(--color-green); border-color: var(--color-green); } + +.history-band { display: grid; gap: 10px; } +.history-actions { justify-content: space-between; gap: 16px; } +.history-actions > div:first-child { display: grid; gap: 3px; } +.history-actions span { color: var(--color-muted); font-size: 12px; } +.button-row { gap: 8px; } +.kpi-strip { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: 1px; background: var(--color-line); border: 1px solid var(--color-line); } +.kpi-item { min-height: 62px; padding: 9px 12px; background: var(--color-surface); display: grid; gap: 2px; } +.kpi-item span { color: var(--color-muted); font-size: 11px; } +.kpi-item strong { font-family: var(--font-mono); font-size: 20px; } +.kpi-item.is-pass strong { color: var(--color-green); } +.kpi-item.is-fail strong { color: var(--color-red); } + +.table-toolbar { gap: 8px; } +.search-field { min-width: 220px; max-width: 380px; flex: 1; } +.table-toolbar select { width: auto; min-width: 120px; } +.table-count { margin-left: auto; color: var(--color-muted); font-family: var(--font-mono); font-size: 12px; } +.table-scroll { overflow: auto; border: 1px solid var(--color-line); background: var(--color-surface); } +.history-table-scroll { max-height: 250px; } +.event-table-scroll { max-height: 220px; } +.data-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.data-table th { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 10px; + color: var(--color-muted); + background: #f1f4f3; + border-bottom: 1px solid var(--color-line); + text-align: left; + font-weight: 700; + white-space: nowrap; +} +.data-table td { padding: 8px 10px; border-bottom: 1px solid #e7ecea; vertical-align: top; } +.data-table tbody tr { cursor: pointer; } +.data-table tbody tr:hover { background: #f2f8f5; } +.data-table tbody tr.is-selected { background: var(--color-green-soft); box-shadow: inset 3px 0 var(--color-green); } +.data-table code { font-family: var(--font-mono); font-size: 11px; } + +.workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + min-height: 520px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-line); +} +.route-stage { min-width: 0; border-right: 1px solid var(--color-line); } +.section-bar { + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 18px; + border-bottom: 1px solid var(--color-line); +} +.section-kicker { display: block; margin-bottom: 3px; color: var(--color-muted); font-size: 10px; font-weight: 750; text-transform: uppercase; } +.route-tools { gap: 14px; } +.inline-field { gap: 7px; } +.inline-field select { width: auto; min-width: 74px; height: 32px; } +.toggle-field { gap: 6px; } +.toggle-field input { width: 16px; height: 16px; accent-color: var(--color-green); } +.route-legend { + min-height: 34px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + padding: 6px 18px; + color: var(--color-muted); + background: var(--color-surface-muted); + border-bottom: 1px solid var(--color-line); + font-size: 11px; +} +.route-legend span { display: inline-flex; align-items: center; gap: 6px; } +.legend-line { display: inline-block; width: 22px; height: 0; border-top: 3px solid; } +.legend-line.approach { border-color: var(--color-approach-path); border-top-style: dashed; } +.legend-line.service { border-color: var(--color-service-path); } +.legend-line.traversed { border-color: var(--color-traversed-path); border-top-width: 5px; } +.legend-line.historical { border-color: var(--color-historical-path); border-top-style: dotted; } +.legend-dot { width: 10px; height: 10px; border: 2px solid; border-radius: 50%; } +.legend-dot.hold { border-color: var(--color-amber); } + +.route-canvas-wrap { position: relative; min-height: 425px; height: calc(100% - 92px); overflow: hidden; } +#route-map { display: block; width: 100%; height: 100%; min-height: 425px; background: #fbfcfc; } +.map-background { fill: url(#map-grid); } +.grid-line { fill: none; stroke: #e7ecea; stroke-width: 1; } +.route-segment { fill: none; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; } +.route-segment.approach { stroke: var(--color-approach-path); stroke-width: 4; stroke-dasharray: 9 8; } +.route-segment.service { stroke: var(--color-service-path); stroke-width: 6; } +.route-segment.historical { stroke: var(--color-historical-path); stroke-width: 3; stroke-dasharray: 3 7; opacity: .82; } +.route-segment.traversed { stroke: var(--color-traversed-path); stroke-width: 8; } +.map-node { fill: #fff; stroke: #7d8a86; stroke-width: 2; vector-effect: non-scaling-stroke; } +.map-node.passed { fill: var(--color-blue-soft); stroke: var(--color-blue); } +.map-node.start { fill: var(--color-green-soft); stroke: var(--color-green); stroke-width: 4; } +.map-node.target { fill: #fff; stroke: var(--color-red); stroke-width: 4; } +.map-node.hold { fill: var(--color-amber-soft); stroke: var(--color-amber); stroke-width: 5; } +.vehicle-marker { fill: var(--color-blue); stroke: #fff; stroke-width: 4; vector-effect: non-scaling-stroke; } +.vehicle-direction { fill: #fff; } +.node-label { fill: #24302d; font-family: var(--font-mono); font-size: 14px; font-weight: 700; paint-order: stroke; stroke: #fff; stroke-width: 4px; stroke-linejoin: round; } +.node-coordinate { fill: var(--color-muted); font-family: var(--font-mono); font-size: 10px; } + +.empty-state, .loading-state { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 7px; + color: var(--color-muted); + background: rgba(251, 252, 252, .94); +} +.empty-state strong, .loading-state strong { color: var(--color-ink); } +.activity-indicator { width: 26px; height: 26px; border: 3px solid #cbd6d2; border-top-color: var(--color-green); border-radius: 50%; animation: rotate 800ms linear infinite; } +@keyframes rotate { to { transform: rotate(360deg); } } + +.task-inspector { min-width: 0; background: #fbfcfc; } +.inspector-heading { align-items: flex-start; } +.inspector-heading h2 { max-width: 190px; overflow-wrap: anywhere; } +.evidence-list { margin: 0; } +.evidence-list > div { display: grid; grid-template-columns: 96px 1fr; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #e4eae8; } +.evidence-list dt { color: var(--color-muted); font-size: 11px; } +.evidence-list dd { margin: 0; text-align: right; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; } +.path-proof, .reason-proof { display: grid; gap: 7px; padding: 13px 16px; border-bottom: 1px solid var(--color-line); } +.path-proof span, .reason-proof span { color: var(--color-muted); font-size: 11px; } +.path-proof output, .reason-proof output { font-family: var(--font-mono); font-size: 11px; line-height: 1.7; overflow-wrap: anywhere; } + +.timeline-panel { background: var(--color-surface); border-bottom: 1px solid var(--color-line); } +.timeline-controls { min-height: 62px; gap: 12px; padding: 9px 18px; } +.timeline-controls > div:first-child { flex: 0 0 140px; } +.playback-buttons { gap: 4px; } +.tick-readout { min-width: 90px; font-family: var(--font-mono); font-size: 12px; color: var(--color-blue); } +#timeline-slider { flex: 1; min-width: 120px; accent-color: var(--color-blue); } +.speed-field { margin-left: auto; } +.timeline-filter-bar { padding: 6px 18px; background: var(--color-surface-muted); border-top: 1px solid var(--color-line); } +.event-table tr.is-current { background: var(--color-blue-soft); } +.event-type { font-weight: 750; } +.event-type.HOLD, .event-type.CLEARANCE_HOLD { color: var(--color-amber); } +.event-type.PLC_RETRY, .event-type.CLEARANCE_PLC_RETRY, .event-type.REDISPATCH { color: var(--color-red); } +.event-type.COMPLETE, .event-type.ASSIGN { color: var(--color-green); } + +.safety-view { padding: 18px 24px 28px; background: var(--color-bg); } +.safety-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } +.audit-grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 8px; } +.audit-item { min-height: 94px; padding: 13px 14px; background: var(--color-surface); border: 1px solid var(--color-line); border-radius: var(--radius); } +.audit-item span { color: var(--color-muted); font-size: 11px; } +.audit-item strong { display: block; margin-top: 9px; font-family: var(--font-mono); font-size: 24px; } +.audit-item small { display: block; margin-top: 5px; color: var(--color-muted); } +.audit-item.is-pass { border-left: 4px solid var(--color-green); } +.audit-item.is-fail { border-left: 4px solid var(--color-red); } +.audit-item.is-fail strong { color: var(--color-red); } +.reason-columns { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; } +.reason-columns section { min-height: 170px; padding: 14px; background: var(--color-surface); border: 1px solid var(--color-line); border-radius: var(--radius); } +.reason-columns h3 { margin-bottom: 10px; } +.reason-list { display: grid; gap: 7px; } +.reason-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; padding-bottom: 6px; border-bottom: 1px solid #e5eae8; } +.reason-row span { overflow-wrap: anywhere; } +.reason-row strong { font-family: var(--font-mono); } +.reason-empty { color: var(--color-muted); } + +.toast { position: fixed; right: 18px; bottom: 18px; z-index: 20; max-width: min(420px, calc(100vw - 36px)); padding: 12px 14px; color: #fff; background: #26332f; border-radius: var(--radius); box-shadow: 0 10px 28px rgba(20, 31, 28, .22); } +.toast.is-error { background: var(--color-red); } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +@media (max-width: 1180px) { + .point-form { grid-template-columns: minmax(150px, 1fr) 36px minmax(150px, 1fr) repeat(3, minmax(90px, .55fr)); } + .field-fault { grid-column: span 2; } + .point-form .primary-button { grid-column: span 1; } + .kpi-strip { grid-template-columns: repeat(3, 1fr); } + .audit-grid { grid-template-columns: repeat(3, 1fr); } +} + +@media (max-width: 900px) { + .app-header { align-items: flex-start; padding: 10px 14px; } + .header-status { max-width: 48%; } + .mode-tabs { padding: 0 10px; overflow-x: auto; } + .mode-tab { padding: 0 12px; white-space: nowrap; } + .control-band, .history-band { padding: 10px 14px; } + .point-form { grid-template-columns: 1fr 36px 1fr; } + .field:not(.field-site), .field-fault, .point-form .primary-button { grid-column: span 1; } + .field-fault { grid-column: span 2; } + .workspace { grid-template-columns: 1fr; } + .route-stage { border-right: 0; } + .task-inspector { border-top: 1px solid var(--color-line); } + .route-canvas-wrap, #route-map { min-height: 390px; } + .timeline-controls { flex-wrap: wrap; } + .timeline-controls > div:first-child { flex-basis: 100%; } + #timeline-slider { order: 5; flex-basis: 100%; } + .audit-grid { grid-template-columns: repeat(2, 1fr); } + .reason-columns { grid-template-columns: 1fr; } +} + +@media (max-width: 580px) { + .app-header { display: grid; gap: 8px; } + .header-status { max-width: none; justify-content: flex-start; } + .brand-block p { max-width: calc(100vw - 74px); } + .meta-value { max-width: 160px; overflow: hidden; text-overflow: ellipsis; } + .point-form { grid-template-columns: 1fr 36px; } + .field-site { grid-column: 1; } + #swap-sites { grid-column: 2; grid-row: 1 / span 2; align-self: center; } + .field:not(.field-site), .field-fault, .point-form .primary-button { grid-column: 1 / -1; } + .history-actions { align-items: flex-start; } + .button-row { flex-wrap: wrap; justify-content: flex-end; } + .kpi-strip { grid-template-columns: repeat(2, 1fr); } + .section-bar { align-items: flex-start; padding: 10px 12px; } + .route-tools { display: grid; gap: 5px; justify-items: end; } + .route-legend { gap: 9px; padding: 6px 12px; } + .route-canvas-wrap, #route-map { min-height: 340px; } + .evidence-list > div { grid-template-columns: 88px 1fr; padding: 9px 12px; } + .timeline-controls { padding: 10px 12px; } + .playback-buttons .icon-button { width: 34px; } + .tick-readout { min-width: 76px; } + .speed-field { margin-left: 0; } + .timeline-filter-bar { padding: 6px 12px; } + .safety-view { padding: 14px 12px 22px; } + .audit-grid { grid-template-columns: 1fr; } + .audit-item { min-height: 76px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation-duration: 1ms !important; } +} diff --git a/javaTest/src/main/resources/visualizer/assets/app.js b/javaTest/src/main/resources/visualizer/assets/app.js new file mode 100644 index 0000000..55d8160 --- /dev/null +++ b/javaTest/src/main/resources/visualizer/assets/app.js @@ -0,0 +1,849 @@ +(function () { + "use strict"; + + var SVG_NS = "http://www.w3.org/2000/svg"; + var state = { + bootstrap: null, + pointResult: null, + historyResult: null, + mode: "point", + selectedTaskId: null, + eventTicks: [0], + tickIndex: 0, + timer: null, + busy: false, + selectedFloor: null + }; + + var elements = {}; + + document.addEventListener("DOMContentLoaded", init); + + function init() { + cacheElements(); + bindEvents(); + loadBootstrap(); + } + + function cacheElements() { + [ + "boundary-text", "service-status", "acceptance-status", "map-version", + "point-controls", "history-controls", "point-form", "source-site", "target-site", + "site-options", "task-type", "priority", "acceleration", "fault-preset", + "swap-sites", "run-point", "run-history", "history-run-state", "history-kpis", + "task-search", "task-status-filter", "task-filter-count", "history-task-body", + "task-workspace", "route-map", "route-empty", "loading-state", "loading-label", + "floor-select", "show-historical", "inspector-task-id", "task-status", + "evidence-endpoints", "evidence-vehicle", "evidence-history-vehicle", + "evidence-assigned-time", "evidence-waiting", "evidence-completion", + "evidence-hold-retry", "evidence-plc", "planned-path-output", "reason-output", + "timeline-panel", "step-back", "play-timeline", "pause-timeline", "step-forward", + "tick-readout", "timeline-slider", "playback-speed", "event-type-filter", + "event-count", "event-table-body", "safety-view", "safety-metrics", + "hold-reasons", "retry-reasons", "failure-reasons", "export-result", + "export-safety", "toast" + ].forEach(function (id) { elements[id] = document.getElementById(id); }); + } + + function bindEvents() { + document.querySelectorAll(".mode-tab").forEach(function (button) { + button.addEventListener("click", function () { activateMode(button.dataset.mode); }); + }); + elements["point-form"].addEventListener("submit", runPointSimulation); + elements["swap-sites"].addEventListener("click", swapSites); + elements["run-history"].addEventListener("click", runHistoricalSimulation); + elements["task-search"].addEventListener("input", renderHistoryTable); + elements["task-status-filter"].addEventListener("change", renderHistoryTable); + elements["floor-select"].addEventListener("change", function () { + state.selectedFloor = Number(elements["floor-select"].value); + renderRoute(); + }); + elements["show-historical"].addEventListener("change", renderRoute); + elements["timeline-slider"].addEventListener("input", function () { + stopPlayback(); + setTickIndex(Number(elements["timeline-slider"].value)); + }); + elements["step-back"].addEventListener("click", function () { stepTimeline(-1); }); + elements["step-forward"].addEventListener("click", function () { stepTimeline(1); }); + elements["play-timeline"].addEventListener("click", startPlayback); + elements["pause-timeline"].addEventListener("click", stopPlayback); + elements["playback-speed"].addEventListener("change", function () { + if (state.timer) { stopPlayback(); startPlayback(); } + }); + elements["event-type-filter"].addEventListener("change", renderEventTable); + elements["export-result"].addEventListener("click", exportEvidence); + elements["export-safety"].addEventListener("click", exportEvidence); + window.addEventListener("resize", debounce(renderRoute, 100)); + } + + async function loadBootstrap() { + setServiceStatus("连接中", "pending"); + try { + state.bootstrap = await api("/api/bootstrap"); + elements["boundary-text"].textContent = state.bootstrap.boundary; + elements["map-version"].textContent = "地图 " + shortHash(state.bootstrap.mapSha256); + populateSites(); + chooseDefaultEndpoints(); + setServiceStatus("本地服务正常", "pass"); + } catch (error) { + setServiceStatus("服务不可用", "fail"); + showToast(error.message, true); + showEmpty("无法加载地图", "请检查本地审计服务"); + } + } + + function populateSites() { + var fragment = document.createDocumentFragment(); + state.bootstrap.sites.forEach(function (site) { + var option = document.createElement("option"); + option.value = site.siteId; + option.label = "F" + site.floor + " · (" + site.x + ", " + site.y + ")"; + fragment.appendChild(option); + }); + elements["site-options"].replaceChildren(fragment); + } + + function chooseDefaultEndpoints() { + if (!state.bootstrap.vehicles.length || !state.bootstrap.sites.length) return; + var sourceId = state.bootstrap.vehicles[0].currentSite; + var source = siteById(sourceId) || state.bootstrap.sites[0]; + var sameFloor = state.bootstrap.sites.filter(function (site) { + return site.floor === source.floor && site.siteId !== source.siteId; + }); + sameFloor.sort(function (left, right) { + return manhattan(right, source) - manhattan(left, source) + || left.siteId.localeCompare(right.siteId); + }); + elements["source-site"].value = source.siteId; + elements["target-site"].value = (sameFloor[0] || state.bootstrap.sites[1]).siteId; + } + + async function runPointSimulation(event) { + event.preventDefault(); + var source = elements["source-site"].value.trim(); + var target = elements["target-site"].value.trim(); + if (!siteById(source) || !siteById(target)) { + showToast("请选择完整地图中存在的起点和终点", true); + return; + } + var request = { + sourceSite: source, + targetSite: target, + taskType: elements["task-type"].value, + priority: Number(elements.priority.value), + acceleration: Number(elements.acceleration.value), + historicalPath: [], + faultPreset: elements["fault-preset"].value + }; + setBusy(true, "正在运行 A → B 调度"); + try { + state.pointResult = await api("/api/simulations/point-to-point", { + method: "POST", + body: JSON.stringify(request) + }); + selectResult(state.pointResult, state.pointResult.tasks[0].simulationTaskId); + showToast("单任务调度完成"); + } catch (error) { + handleRunError(error); + } finally { + setBusy(false); + } + } + + async function runHistoricalSimulation() { + setBusy(true, "正在重新运行 240 条历史任务"); + elements["history-run-state"].textContent = "运行中"; + try { + state.historyResult = await api("/api/simulations/historical", { + method: "POST", + body: "{}" + }); + elements["history-run-state"].textContent = "最近运行:" + new Date().toLocaleTimeString("zh-CN", { hour12: false }); + renderHistorySummary(); + renderHistoryTable(); + selectResult(state.historyResult, state.historyResult.tasks[0].simulationTaskId); + elements["export-result"].disabled = false; + elements["export-safety"].disabled = false; + showToast("240 条历史任务仿真完成"); + } catch (error) { + elements["history-run-state"].textContent = "运行失败"; + handleRunError(error); + } finally { + setBusy(false); + } + } + + function activateMode(mode) { + state.mode = mode; + stopPlayback(); + document.querySelectorAll(".mode-tab").forEach(function (button) { + var active = button.dataset.mode === mode; + button.classList.toggle("is-active", active); + button.setAttribute("aria-selected", String(active)); + }); + elements["point-controls"].hidden = mode !== "point"; + elements["history-controls"].hidden = mode !== "history"; + elements["task-workspace"].hidden = mode === "safety"; + elements["timeline-panel"].hidden = mode === "safety"; + elements["safety-view"].hidden = mode !== "safety"; + + if (mode === "point" && state.pointResult) { + selectResult(state.pointResult, state.pointResult.tasks[0].simulationTaskId); + } else if (mode === "history" && state.historyResult) { + renderHistorySummary(); + renderHistoryTable(); + selectResult(state.historyResult, state.selectedTaskId || state.historyResult.tasks[0].simulationTaskId); + } else if (mode === "safety") { + renderSafety(); + } + } + + function selectResult(result, taskId) { + state.selectedTaskId = taskId; + state.tickIndex = 0; + var taskEvents = eventsForTask(result, taskId); + state.eventTicks = unique(taskEvents.map(function (event) { return Number(event.tick); })) + .sort(function (left, right) { return left - right; }); + if (!state.eventTicks.length) state.eventTicks = [0]; + elements["timeline-slider"].min = "0"; + elements["timeline-slider"].max = String(state.eventTicks.length - 1); + elements["timeline-slider"].value = "0"; + populateEventTypes(taskEvents); + populateFloors(); + renderSelectedTask(); + updateAcceptance(result); + } + + function currentResult() { + if (state.mode === "history") return state.historyResult; + if (state.mode === "point") return state.pointResult; + return state.historyResult || state.pointResult; + } + + function selectedTask() { + var result = currentResult(); + if (!result) return null; + return result.tasks.find(function (task) { + return task.simulationTaskId === state.selectedTaskId; + }) || null; + } + + function renderSelectedTask() { + var task = selectedTask(); + if (!task) { + showEmpty("等待调度结果", "路径证据将在本区域显示"); + resetInspector(); + return; + } + elements["route-empty"].hidden = true; + renderInspector(task); + renderRoute(); + renderTimeline(); + } + + function renderInspector(task) { + elements["inspector-task-id"].textContent = task.taskNo || task.simulationTaskId; + setTaskStatus(task.status); + setText("evidence-endpoints", task.physicalStartSite + " → " + task.physicalEndSite); + setText("evidence-vehicle", task.simulatedVehicleId || "未分配"); + setText("evidence-history-vehicle", task.historicalVehicleId || "无对照"); + setText("evidence-assigned-time", formatMillis(task.firstAssignedMillis)); + setText("evidence-waiting", formatDuration(task.waitingMillis)); + setText("evidence-completion", formatDuration(task.simulationDurationMillis)); + setText("evidence-hold-retry", task.holdCount + " / " + task.retryCount); + setText("evidence-plc", String(task.protocolAttempts)); + elements["planned-path-output"].textContent = task.plannedPath.length + ? task.plannedPath.join(" → ") : "无规划路径"; + elements["reason-output"].textContent = task.reason || task.status; + } + + function populateFloors() { + var task = selectedTask(); + if (!task || !state.bootstrap) return; + var ids = [].concat(task.plannedPath || [], task.historicalPath || []); + eventsForTask(currentResult(), task.simulationTaskId).forEach(function (event) { + if (event.fromSite) ids.push(event.fromSite); + if (event.toSite) ids.push(event.toSite); + }); + var floors = unique(ids.map(siteById).filter(Boolean).map(function (site) { return site.floor; })); + if (!floors.length) floors = state.bootstrap.floors.slice(); + state.selectedFloor = floors.indexOf(state.selectedFloor) >= 0 ? state.selectedFloor : floors[0]; + var fragment = document.createDocumentFragment(); + floors.forEach(function (floor) { + var option = document.createElement("option"); + option.value = String(floor); + option.textContent = floor + "F"; + option.selected = floor === state.selectedFloor; + fragment.appendChild(option); + }); + elements["floor-select"].replaceChildren(fragment); + } + + function renderRoute() { + var task = selectedTask(); + if (!task || !state.bootstrap) return; + var svg = elements["route-map"]; + var old = svg.querySelector("#dynamic-route-layer"); + if (old) old.remove(); + var layer = svgElement("g", { id: "dynamic-route-layer" }); + svg.appendChild(layer); + + var events = eventsForTask(currentResult(), task.simulationTaskId); + var moves = events.filter(function (event) { return event.eventType === "MOVE"; }); + var currentTick = tickValue(); + var traversedMoves = moves.filter(function (event) { return Number(event.tick) <= currentTick; }); + var approach = approachPath(task, moves); + var historical = elements["show-historical"].checked ? task.historicalPath : []; + var currentSite = currentVehicleSite(task, traversedMoves); + var allIds = unique([].concat(task.plannedPath, historical, approach, + moves.reduce(function (values, event) { + if (event.fromSite) values.push(event.fromSite); + if (event.toSite) values.push(event.toSite); + return values; + }, []), [task.physicalStartSite, task.physicalEndSite, currentSite])); + var sites = allIds.map(siteById).filter(function (site) { + return site && site.floor === state.selectedFloor; + }); + if (!sites.length) { + showEmpty("当前楼层没有路径节点", "请选择包含任务路径的楼层"); + return; + } + elements["route-empty"].hidden = true; + var project = projector(sites); + + drawPath(layer, historical, "historical", project); + drawPath(layer, approach, "approach", project); + drawPath(layer, task.plannedPath, "service", project); + drawMoveSegments(layer, traversedMoves, "traversed", project); + + var passedIds = new Set(); + traversedMoves.forEach(function (event) { + if (event.fromSite) passedIds.add(event.fromSite); + if (event.toSite) passedIds.add(event.toSite); + }); + var lastEvent = lastEventAt(events, currentTick); + var holdSite = lastEvent && isHold(lastEvent.eventType) + ? (lastEvent.toSite || lastEvent.fromSite) : null; + var labelEvery = sites.length <= 12 ? 1 : sites.length <= 28 ? 3 : 5; + sites.forEach(function (site, index) { + drawNode(layer, site, project, { + passed: passedIds.has(site.siteId), + start: site.siteId === task.physicalStartSite, + target: site.siteId === task.physicalEndSite, + hold: site.siteId === holdSite, + label: index % labelEvery === 0 || site.siteId === currentSite + || site.siteId === task.physicalStartSite || site.siteId === task.physicalEndSite + }); + }); + var vehicleSite = siteById(currentSite); + if (vehicleSite && vehicleSite.floor === state.selectedFloor) { + drawVehicle(layer, vehicleSite, task.simulatedVehicleId, project); + } + } + + function projector(sites) { + var xs = sites.map(function (site) { return Number(site.x); }); + var ys = sites.map(function (site) { return Number(site.y); }); + var minX = Math.min.apply(Math, xs); + var maxX = Math.max.apply(Math, xs); + var minY = Math.min.apply(Math, ys); + var maxY = Math.max.apply(Math, ys); + var width = Math.max(1, maxX - minX); + var height = Math.max(1, maxY - minY); + var scale = Math.min(860 / width, 430 / height); + var drawWidth = width * scale; + var drawHeight = height * scale; + var offsetX = (1000 - drawWidth) / 2; + var offsetY = (560 - drawHeight) / 2; + return function (site) { + if (!site || site.floor !== state.selectedFloor) return null; + return { + x: offsetX + (Number(site.x) - minX) * scale, + y: 560 - offsetY - (Number(site.y) - minY) * scale + }; + }; + } + + function drawPath(layer, ids, className, project) { + for (var index = 1; index < ids.length; index += 1) { + drawSegment(layer, ids[index - 1], ids[index], className, project); + } + } + + function drawMoveSegments(layer, moves, className, project) { + moves.forEach(function (event) { + drawSegment(layer, event.fromSite, event.toSite, className, project); + }); + } + + function drawSegment(layer, fromId, toId, className, project) { + var from = project(siteById(fromId)); + var to = project(siteById(toId)); + if (!from || !to) return; + var line = svgElement("line", { + x1: from.x, y1: from.y, x2: to.x, y2: to.y, + class: "route-segment " + className + }); + var title = svgElement("title"); + title.textContent = fromId + " → " + toId; + line.appendChild(title); + layer.appendChild(line); + } + + function drawNode(layer, site, project, flags) { + var point = project(site); + if (!point) return; + var classes = ["map-node"]; + ["passed", "start", "target", "hold"].forEach(function (name) { + if (flags[name]) classes.push(name); + }); + var circle = svgElement("circle", { + cx: point.x, cy: point.y, r: flags.start || flags.target ? 10 : 6, + class: classes.join(" ") + }); + var title = svgElement("title"); + title.textContent = site.siteId + " · " + site.floor + "F · (" + site.x + ", " + site.y + ")"; + circle.appendChild(title); + layer.appendChild(circle); + if (flags.label) { + var label = svgElement("text", { x: point.x + 11, y: point.y - 10, class: "node-label" }); + label.textContent = site.siteId; + layer.appendChild(label); + if (flags.start || flags.target) { + var coordinate = svgElement("text", { x: point.x + 11, y: point.y + 7, class: "node-coordinate" }); + coordinate.textContent = "(" + site.x + ", " + site.y + ")"; + layer.appendChild(coordinate); + } + } + } + + function drawVehicle(layer, site, vehicleId, project) { + var point = project(site); + if (!point) return; + var marker = svgElement("g", { transform: "translate(" + point.x + " " + point.y + ")" }); + var diamond = svgElement("path", { d: "M 0 -15 L 15 0 L 0 15 L -15 0 Z", class: "vehicle-marker" }); + var title = svgElement("title"); + title.textContent = "车辆 " + (vehicleId || "--") + " · " + site.siteId; + diamond.appendChild(title); + marker.appendChild(diamond); + var label = svgElement("text", { x: 0, y: 4, "text-anchor": "middle", class: "vehicle-direction" }); + label.textContent = vehicleId || "?"; + marker.appendChild(label); + layer.appendChild(marker); + } + + function renderTimeline() { + elements["timeline-slider"].value = String(state.tickIndex); + elements["tick-readout"].textContent = "tick " + tickValue(); + renderRoute(); + renderEventTable(); + } + + function renderEventTable() { + var result = currentResult(); + var task = selectedTask(); + if (!result || !task) { + elements["event-table-body"].replaceChildren(); + elements["event-count"].textContent = "0 个事件"; + return; + } + var type = elements["event-type-filter"].value; + var events = eventsForTask(result, task.simulationTaskId).filter(function (event) { + return type === "ALL" || event.eventType === type; + }); + elements["event-count"].textContent = events.length + " 个事件"; + var currentTick = tickValue(); + var fragment = document.createDocumentFragment(); + events.forEach(function (event) { + var row = document.createElement("tr"); + row.classList.toggle("is-current", Number(event.tick) === currentTick); + row.addEventListener("click", function () { + var index = state.eventTicks.indexOf(Number(event.tick)); + if (index >= 0) setTickIndex(index); + }); + appendCell(row, event.tick, true); + var typeCell = appendCell(row, event.eventType); + typeCell.className = "event-type " + event.eventType; + appendCell(row, event.vehicleId || "--", true); + appendCell(row, event.reason || "--"); + appendCell(row, event.fromSite || event.toSite + ? (event.fromSite || "--") + " → " + (event.toSite || "--") : "--", true); + fragment.appendChild(row); + }); + elements["event-table-body"].replaceChildren(fragment); + var currentRow = elements["event-table-body"].querySelector(".is-current"); + if (currentRow) currentRow.scrollIntoView({ block: "nearest" }); + } + + function populateEventTypes(events) { + var current = elements["event-type-filter"].value; + var types = unique(events.map(function (event) { return event.eventType; })).sort(); + var fragment = document.createDocumentFragment(); + var all = document.createElement("option"); + all.value = "ALL"; + all.textContent = "全部事件"; + fragment.appendChild(all); + types.forEach(function (type) { + var option = document.createElement("option"); + option.value = type; + option.textContent = type; + fragment.appendChild(option); + }); + elements["event-type-filter"].replaceChildren(fragment); + elements["event-type-filter"].value = types.indexOf(current) >= 0 ? current : "ALL"; + } + + function startPlayback() { + if (!selectedTask() || state.timer) return; + if (state.tickIndex >= state.eventTicks.length - 1) setTickIndex(0); + var delay = Number(elements["playback-speed"].value); + state.timer = window.setInterval(function () { + if (state.tickIndex >= state.eventTicks.length - 1) { + stopPlayback(); + } else { + setTickIndex(state.tickIndex + 1); + } + }, delay); + } + + function stopPlayback() { + if (state.timer) window.clearInterval(state.timer); + state.timer = null; + } + + function stepTimeline(delta) { + stopPlayback(); + setTickIndex(Math.max(0, Math.min(state.eventTicks.length - 1, state.tickIndex + delta))); + } + + function setTickIndex(index) { + state.tickIndex = index; + renderTimeline(); + } + + function renderHistorySummary() { + if (!state.historyResult) return; + var summary = state.historyResult.summary; + var values = [ + ["完成", summary.completedCount, summary.completedCount === summary.inputCount], + ["等待", summary.waitingCount, summary.waitingCount === 0], + ["失败", summary.failedCount, summary.failedCount === 0], + ["重新选车", summary.reassignmentCount, true], + ["路径变化", summary.replannedPathDifferenceCount, true], + ["总 tick", summary.tickCount, true] + ]; + var fragment = document.createDocumentFragment(); + values.forEach(function (entry) { + var item = document.createElement("div"); + item.className = "kpi-item " + (entry[2] ? "is-pass" : "is-fail"); + var label = document.createElement("span"); + label.textContent = entry[0]; + var value = document.createElement("strong"); + value.textContent = String(entry[1]); + item.append(label, value); + fragment.appendChild(item); + }); + elements["history-kpis"].replaceChildren(fragment); + } + + function renderHistoryTable() { + if (!state.historyResult) { + elements["history-task-body"].replaceChildren(); + elements["task-filter-count"].textContent = "0 条"; + return; + } + var query = elements["task-search"].value.trim().toLowerCase(); + var status = elements["task-status-filter"].value; + var tasks = state.historyResult.tasks.filter(function (task) { + if (status !== "ALL" && task.status !== status) return false; + var haystack = [task.taskNo, task.simulationTaskId, task.physicalStartSite, + task.physicalEndSite, task.historicalVehicleId, task.simulatedVehicleId] + .join(" ").toLowerCase(); + return !query || haystack.indexOf(query) >= 0; + }); + elements["task-filter-count"].textContent = tasks.length + " 条"; + var fragment = document.createDocumentFragment(); + tasks.forEach(function (task) { + var row = document.createElement("tr"); + row.dataset.taskId = task.simulationTaskId; + row.classList.toggle("is-selected", task.simulationTaskId === state.selectedTaskId); + row.addEventListener("click", function () { + selectResult(state.historyResult, task.simulationTaskId); + renderHistoryTable(); + }); + appendCell(row, task.taskNo || task.simulationTaskId, true); + appendCell(row, task.physicalStartSite + " → " + task.physicalEndSite, true); + appendCell(row, task.priority, true); + appendCell(row, task.historicalVehicleId || "--", true); + appendCell(row, task.simulatedVehicleId || "--", true); + appendCell(row, task.pathChanged ? "已重规划" : "一致"); + appendCell(row, task.status); + appendCell(row, formatDuration(task.waitingMillis), true); + appendCell(row, formatDuration(task.simulationDurationMillis), true); + fragment.appendChild(row); + }); + elements["history-task-body"].replaceChildren(fragment); + } + + function renderSafety() { + var result = state.historyResult || state.pointResult; + if (!result) { + elements["safety-metrics"].replaceChildren(auditItem("审计状态", "尚未运行", false, "无仿真证据")); + renderReasons(elements["hold-reasons"], {}); + renderReasons(elements["retry-reasons"], {}); + renderReasons(elements["failure-reasons"], {}); + return; + } + var invariants = result.invariants; + var metrics = [ + ["节点双占", invariants.nodeDoubleOccupancyViolations, invariants.nodeDoubleOccupancyViolations === 0, "必须为 0"], + ["资源超容量", invariants.capacityViolations, invariants.capacityViolations === 0, "必须为 0"], + ["对向换边", invariants.oppositeEdgeViolations, invariants.oppositeEdgeViolations === 0, "必须为 0"], + ["死锁", invariants.deadlocks, invariants.deadlocks === 0, "必须为 0"], + ["重复归档", invariants.duplicateArchiveAttempts, invariants.duplicateArchiveAttempts === 0, "必须为 0"], + ["WMS 重复业务效果", invariants.duplicateWmsBusinessEffects, invariants.duplicateWmsBusinessEffects === 0, "必须为 0"], + ["归档完整", result.archive.count + " / " + result.archive.expected, result.archive.complete, "完成任务均归档"], + ["WMS 幂等", result.wms.businessEffects + " / " + result.wms.deliveryAttempts, result.wms.idempotent, "重复投递无重复效果"] + ]; + var fragment = document.createDocumentFragment(); + metrics.forEach(function (entry) { + fragment.appendChild(auditItem(entry[0], entry[1], entry[2], entry[3])); + }); + elements["safety-metrics"].replaceChildren(fragment); + renderReasons(elements["hold-reasons"], result.summary.holdReasons); + renderReasons(elements["retry-reasons"], result.summary.retryReasons); + renderReasons(elements["failure-reasons"], result.summary.failureReasons); + updateAcceptance(result); + } + + function renderReasons(container, reasons) { + var entries = Object.keys(reasons || {}).map(function (key) { return [key, reasons[key]]; }); + entries.sort(function (left, right) { return right[1] - left[1] || left[0].localeCompare(right[0]); }); + if (!entries.length) { + var empty = document.createElement("span"); + empty.className = "reason-empty"; + empty.textContent = "无记录"; + container.replaceChildren(empty); + return; + } + var fragment = document.createDocumentFragment(); + entries.forEach(function (entry) { + var row = document.createElement("div"); + row.className = "reason-row"; + var label = document.createElement("span"); + label.textContent = entry[0]; + var count = document.createElement("strong"); + count.textContent = String(entry[1]); + row.append(label, count); + fragment.appendChild(row); + }); + container.replaceChildren(fragment); + } + + function auditItem(label, value, passed, note) { + var item = document.createElement("div"); + item.className = "audit-item " + (passed ? "is-pass" : "is-fail"); + var title = document.createElement("span"); + title.textContent = label; + var metric = document.createElement("strong"); + metric.textContent = String(value); + var detail = document.createElement("small"); + detail.textContent = note; + item.append(title, metric, detail); + return item; + } + + function updateAcceptance(result) { + if (!result) return; + var passed = result.summary.acceptancePassed + && result.invariants.totalSafetyViolations === 0 + && result.invariants.deadlocks === 0 + && result.archive.complete && result.wms.idempotent; + elements["acceptance-status"].textContent = passed ? "验收通过" : "验收不通过"; + elements["acceptance-status"].className = "status-chip " + (passed ? "status-pass" : "status-fail"); + } + + function exportEvidence() { + var result = currentResult() || state.historyResult || state.pointResult; + if (!result) return; + var blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json;charset=utf-8" }); + var url = URL.createObjectURL(blob); + var link = document.createElement("a"); + link.href = url; + link.download = "dispatch-audit-" + new Date().toISOString().replace(/[:.]/g, "-") + ".json"; + link.click(); + URL.revokeObjectURL(url); + } + + function approachPath(task, moves) { + var firstMove = moves[0]; + var initial = firstMove ? firstMove.fromSite : initialVehicleSite(task.simulatedVehicleId); + if (!initial) return []; + var path = [initial]; + if (initial === task.physicalStartSite) return path; + for (var index = 0; index < moves.length; index += 1) { + var event = moves[index]; + if (path[path.length - 1] !== event.fromSite) path.push(event.fromSite); + path.push(event.toSite); + if (event.toSite === task.physicalStartSite) break; + } + return path; + } + + function currentVehicleSite(task, traversedMoves) { + if (traversedMoves.length) return traversedMoves[traversedMoves.length - 1].toSite; + return initialVehicleSite(task.simulatedVehicleId) || task.physicalStartSite; + } + + function initialVehicleSite(vehicleId) { + if (!state.bootstrap) return null; + var vehicle = state.bootstrap.vehicles.find(function (item) { return item.vehicleId === vehicleId; }); + return vehicle ? vehicle.currentSite : null; + } + + function eventsForTask(result, taskId) { + if (!result) return []; + return result.events.filter(function (event) { return event.taskId === taskId; }) + .sort(function (left, right) { return Number(left.tick) - Number(right.tick); }); + } + + function lastEventAt(events, tick) { + var found = null; + events.forEach(function (event) { + if (Number(event.tick) <= tick) found = event; + }); + return found; + } + + function isHold(type) { + return type === "HOLD" || type === "CLEARANCE_HOLD" || type === "PLC_RETRY" + || type === "CLEARANCE_PLC_RETRY"; + } + + function tickValue() { + return Number(state.eventTicks[state.tickIndex] || 0); + } + + function swapSites() { + var source = elements["source-site"].value; + elements["source-site"].value = elements["target-site"].value; + elements["target-site"].value = source; + } + + function setBusy(busy, label) { + state.busy = busy; + elements["loading-state"].hidden = !busy; + if (label) elements["loading-label"].textContent = label; + elements["run-point"].disabled = busy; + elements["run-history"].disabled = busy; + } + + function handleRunError(error) { + showToast(error.message, true); + showEmpty(error.code === "UNKNOWN_SITE" ? "未知站点" : "调度未完成", error.message); + elements["acceptance-status"].textContent = "验收不通过"; + elements["acceptance-status"].className = "status-chip status-fail"; + } + + function showEmpty(title, detail) { + elements["route-empty"].hidden = false; + var strong = elements["route-empty"].querySelector("strong"); + var span = elements["route-empty"].querySelector("span"); + strong.textContent = title; + span.textContent = detail; + } + + function resetInspector() { + ["inspector-task-id", "evidence-endpoints", "evidence-vehicle", "evidence-history-vehicle", + "evidence-assigned-time", "evidence-waiting", "evidence-completion", + "evidence-hold-retry", "evidence-plc", "planned-path-output", "reason-output"] + .forEach(function (id) { setText(id, "--"); }); + setTaskStatus("--"); + } + + function setTaskStatus(status) { + var className = status === "COMPLETED" ? "status-pass" + : status === "FAILED" ? "status-fail" + : status === "WAITING" ? "status-hold" : "status-neutral"; + elements["task-status"].textContent = status; + elements["task-status"].className = "status-chip " + className; + } + + function setServiceStatus(text, tone) { + elements["service-status"].textContent = text; + elements["service-status"].className = "status-chip status-" + tone; + } + + function showToast(message, error) { + elements.toast.textContent = message; + elements.toast.className = "toast" + (error ? " is-error" : ""); + elements.toast.hidden = false; + window.clearTimeout(showToast.timer); + showToast.timer = window.setTimeout(function () { elements.toast.hidden = true; }, 3600); + } + + async function api(path, options) { + var request = Object.assign({ headers: { "Accept": "application/json" } }, options || {}); + if (request.body) request.headers["Content-Type"] = "application/json; charset=utf-8"; + var response = await fetch(path, request); + var payload; + try { payload = await response.json(); } + catch (error) { payload = { code: "INVALID_RESPONSE", message: "服务返回了无法解析的数据" }; } + if (!response.ok) { + var failure = new Error(payload.message || ("请求失败:" + response.status)); + failure.code = payload.code; + throw failure; + } + return payload; + } + + function siteById(siteId) { + if (!state.bootstrap || !siteId) return null; + return state.bootstrap.sites.find(function (site) { return site.siteId === siteId; }) || null; + } + + function appendCell(row, value, mono) { + var cell = document.createElement("td"); + if (mono) { + var code = document.createElement("code"); + code.textContent = value === null || value === undefined ? "--" : String(value); + cell.appendChild(code); + } else { + cell.textContent = value === null || value === undefined ? "--" : String(value); + } + row.appendChild(cell); + return cell; + } + + function svgElement(name, attributes) { + var node = document.createElementNS(SVG_NS, name); + Object.keys(attributes || {}).forEach(function (key) { + node.setAttribute(key, String(attributes[key])); + }); + return node; + } + + function formatDuration(value) { + if (value === null || value === undefined) return "--"; + var millis = Number(value); + if (millis < 1000) return millis + " ms"; + if (millis < 60_000) return (millis / 1000).toFixed(1) + " s"; + return (millis / 60_000).toFixed(1) + " min"; + } + + function formatMillis(value) { + return value === null || value === undefined ? "--" : "T+" + formatDuration(value); + } + + function setText(id, value) { elements[id].textContent = value; } + function shortHash(value) { return value ? value.slice(0, 10) : "--"; } + function manhattan(left, right) { return Math.abs(left.x - right.x) + Math.abs(left.y - right.y); } + function unique(values) { return Array.from(new Set(values.filter(function (value) { return value !== null && value !== undefined && value !== ""; }))); } + function debounce(callback, delay) { + var timer; + return function () { + window.clearTimeout(timer); + timer = window.setTimeout(callback, delay); + }; + } +}()); diff --git a/javaTest/src/main/resources/visualizer/index.html b/javaTest/src/main/resources/visualizer/index.html new file mode 100644 index 0000000..f6fc128 --- /dev/null +++ b/javaTest/src/main/resources/visualizer/index.html @@ -0,0 +1,219 @@ + + + + + + + + javaTest 调度路径审计台 + + + +
+
+
+ +
+

调度路径审计台

+

离线内存仿真

+
+
+
+ 服务连接中 + 尚未验收 + 地图 -- +
+
+ + + +
+
+ + + + + + + + + +
+
+ + + +
+
+
+
+ 真实地图坐标 +

算法规划路径

+
+
+ + +
+
+
+ 接近路径 + A → B 规划 + 已通过 + 历史参考 + HOLD +
+
+ + + + + + + + +
+ 等待调度结果 + 路径证据将在本区域显示 +
+ +
+
+ + +
+ +
+
+
+ 逐 tick 证据 +

调度时间线

+
+
+ + + + +
+ tick -- + + +
+
+ + 0 个事件 +
+
+ + + +
tick事件车辆原因移动
+
+
+ + + + +
+ + + diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java index 44530a7..3ec4620 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java @@ -92,6 +92,38 @@ class HistoricalDispatchVisualizerServerTest { assertEquals(0, map(map(response.body).get("invariants")).get("totalSafetyViolations")); } + @Test + void servesTheVisualizerShellAndAssets() throws Exception { + HttpResult page = request("GET", "/", null); + HttpResult stylesheet = request("GET", "/assets/app.css", null); + HttpResult script = request("GET", "/assets/app.js", null); + + assertEquals(200, page.status); + assertEquals("text/html; charset=utf-8", page.contentType); + assertTrue(page.contentSecurityPolicy.contains("default-src 'self'")); + assertTrue(page.contentSecurityPolicy.contains("frame-ancestors 'none'")); + assertTrue(page.body.contains("调度路径审计台")); + assertTrue(page.body.contains("id=\"route-map\"")); + assertTrue(page.body.contains("id=\"timeline-slider\"")); + assertEquals(200, stylesheet.status); + assertEquals("text/css; charset=utf-8", stylesheet.contentType); + assertTrue(stylesheet.body.contains("--color-service-path")); + assertEquals(200, script.status); + assertEquals("application/javascript; charset=utf-8", script.contentType); + assertTrue(script.body.contains("/api/simulations/point-to-point")); + } + + @Test + void rejectsWrongStaticMethodsAndUnknownAssets() throws Exception { + HttpResult wrongMethod = request("POST", "/assets/app.js", "{}"); + HttpResult unknownAsset = request("GET", "/assets/missing.js", null); + + assertEquals(405, wrongMethod.status); + assertEquals("METHOD_NOT_ALLOWED", map(wrongMethod.body).get("code")); + assertEquals(404, unknownAsset.status); + assertEquals("NOT_FOUND", map(unknownAsset.body).get("code")); + } + @Test void rejectsUnknownRoutesWrongMethodsMalformedAndOversizedBodies() throws Exception { HttpResult unknown = request("GET", "/api/unknown", null); @@ -129,11 +161,13 @@ class HistoricalDispatchVisualizerServerTest { } } int status = connection.getResponseCode(); + String contentType = connection.getHeaderField("Content-Type"); + String contentSecurityPolicy = connection.getHeaderField("Content-Security-Policy"); InputStream stream = status >= 400 ? connection.getErrorStream() : connection.getInputStream(); String response = stream == null ? "" : read(stream); connection.disconnect(); - return new HttpResult(status, response); + return new HttpResult(status, response, contentType, contentSecurityPolicy); } private String read(InputStream input) throws IOException { @@ -162,10 +196,15 @@ class HistoricalDispatchVisualizerServerTest { private static final class HttpResult { private final int status; private final String body; + private final String contentType; + private final String contentSecurityPolicy; - private HttpResult(int status, String body) { + private HttpResult(int status, String body, String contentType, + String contentSecurityPolicy) { this.status = status; this.body = body; + this.contentType = contentType; + this.contentSecurityPolicy = contentSecurityPolicy; } } }