feat(javaTest): add dispatch audit visualizer

This commit is contained in:
zgw
2026-08-05 16:09:52 +08:00
parent f80f92dca3
commit 953f78d97e
6 changed files with 1590 additions and 2 deletions

View File

@@ -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

View File

@@ -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<String, Object> health = new LinkedHashMap<String, Object>();
@@ -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<String, Object> runSimulation(SimulationCall call) {
if (!simulationBusy.compareAndSet(false, true)) {
throw new VisualizerHttpException(409, "SIMULATION_BUSY", "已有仿真正在运行");

View File

@@ -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; }
}

View File

@@ -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);
};
}
}());

View File

@@ -0,0 +1,219 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<link rel="icon" href="data:,">
<title>javaTest 调度路径审计台</title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<div class="app-shell">
<header id="app-header" class="app-header">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<div>
<h1>调度路径审计台</h1>
<p id="boundary-text">离线内存仿真</p>
</div>
</div>
<div class="header-status">
<span id="service-status" class="status-chip status-pending">服务连接中</span>
<span id="acceptance-status" class="status-chip status-neutral">尚未验收</span>
<span id="map-version" class="meta-value">地图 --</span>
</div>
</header>
<nav id="mode-tabs" class="mode-tabs" aria-label="审计模式">
<button type="button" class="mode-tab is-active" data-mode="point" aria-selected="true">A → B 单任务</button>
<button type="button" class="mode-tab" data-mode="history" aria-selected="false">240 条历史任务</button>
<button type="button" class="mode-tab" data-mode="safety" aria-selected="false">安全审计</button>
</nav>
<section id="point-controls" class="control-band" aria-label="单任务参数">
<form id="point-form" class="point-form">
<label class="field field-site">
<span>起点 A</span>
<input id="source-site" name="sourceSite" list="site-options" autocomplete="off" required>
</label>
<button id="swap-sites" type="button" class="icon-button" title="交换起点和终点" aria-label="交换起点和终点">⇄</button>
<label class="field field-site">
<span>终点 B</span>
<input id="target-site" name="targetSite" list="site-options" autocomplete="off" required>
</label>
<datalist id="site-options"></datalist>
<label class="field">
<span>任务类型</span>
<select id="task-type" name="taskType">
<option value="INBOUND">入库</option>
<option value="OUTBOUND">出库</option>
<option value="RELOCATION">移位</option>
</select>
</label>
<label class="field field-compact">
<span>优先级</span>
<input id="priority" name="priority" type="number" min="0" max="9" value="1" required>
</label>
<label class="field">
<span>加速</span>
<select id="acceleration" name="acceleration">
<option value="100">100×</option>
<option value="10">10×</option>
</select>
</label>
<label class="field field-fault">
<span>故障预设</span>
<select id="fault-preset" name="faultPreset">
<option value="NONE">无故障</option>
<option value="PLC_FIRST_SEND_FAILURE">PLC 首次发送失败</option>
<option value="VEHICLE_RESTART">车辆重启</option>
<option value="LOW_BATTERY">全车低电量后恢复</option>
<option value="DISCONNECT_RECONNECT">全车断线后重连</option>
</select>
</label>
<button id="run-point" type="submit" class="primary-button"><span aria-hidden="true">▶</span>运行调度</button>
</form>
</section>
<section id="history-controls" class="history-band" hidden>
<div class="history-actions">
<div>
<strong>真实历史负载</strong>
<span id="history-run-state">尚未运行</span>
</div>
<div class="button-row">
<button id="export-result" type="button" class="secondary-button" disabled>导出证据</button>
<button id="run-history" type="button" class="primary-button"><span aria-hidden="true">▶</span>运行 240 条</button>
</div>
</div>
<div id="history-kpis" class="kpi-strip"></div>
<div class="table-toolbar">
<label class="search-field"><span class="sr-only">搜索任务</span><input id="task-search" type="search" placeholder="任务号 / 站点 / 车辆"></label>
<select id="task-status-filter" aria-label="按任务状态筛选">
<option value="ALL">全部状态</option>
<option value="COMPLETED">完成</option>
<option value="WAITING">等待</option>
<option value="FAILED">失败</option>
</select>
<span id="task-filter-count" class="table-count">0 条</span>
</div>
<div class="table-scroll history-table-scroll">
<table class="data-table history-table">
<thead><tr><th>任务</th><th>A → B</th><th>优先级</th><th>历史车</th><th>模拟车</th><th>路径</th><th>状态</th><th>等待</th><th>完成</th></tr></thead>
<tbody id="history-task-body"></tbody>
</table>
</div>
</section>
<main id="task-workspace" class="workspace">
<section id="route-stage" class="route-stage" aria-labelledby="route-title">
<div class="section-bar">
<div>
<span class="section-kicker">真实地图坐标</span>
<h2 id="route-title">算法规划路径</h2>
</div>
<div class="route-tools">
<label class="inline-field"><span>楼层</span><select id="floor-select"></select></label>
<label class="toggle-field"><input id="show-historical" type="checkbox" checked><span>历史参考</span></label>
</div>
</div>
<div class="route-legend" aria-label="路径图例">
<span><i class="legend-line approach"></i>接近路径</span>
<span><i class="legend-line service"></i>A → B 规划</span>
<span><i class="legend-line traversed"></i>已通过</span>
<span><i class="legend-line historical"></i>历史参考</span>
<span><i class="legend-dot hold"></i>HOLD</span>
</div>
<div class="route-canvas-wrap">
<svg id="route-map" viewBox="0 0 1000 560" role="img" aria-label="调度中心规划路径">
<defs>
<pattern id="map-grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" class="grid-line"></path>
</pattern>
</defs>
<rect width="1000" height="560" class="map-background"></rect>
</svg>
<div id="route-empty" class="empty-state">
<strong>等待调度结果</strong>
<span>路径证据将在本区域显示</span>
</div>
<div id="loading-state" class="loading-state" hidden>
<span class="activity-indicator" aria-hidden="true"></span>
<strong id="loading-label">正在运行调度</strong>
</div>
</div>
</section>
<aside id="task-inspector" class="task-inspector" aria-label="任务检查器">
<div class="section-bar inspector-heading">
<div><span class="section-kicker">任务证据</span><h2 id="inspector-task-id">未选择任务</h2></div>
<span id="task-status" class="status-chip status-neutral">--</span>
</div>
<dl class="evidence-list">
<div><dt>业务端点</dt><dd id="evidence-endpoints">--</dd></div>
<div><dt>中心选车</dt><dd id="evidence-vehicle">--</dd></div>
<div><dt>历史车辆</dt><dd id="evidence-history-vehicle">--</dd></div>
<div><dt>首次分配</dt><dd id="evidence-assigned-time">--</dd></div>
<div><dt>等待时间</dt><dd id="evidence-waiting">--</dd></div>
<div><dt>完成时间</dt><dd id="evidence-completion">--</dd></div>
<div><dt>HOLD / 重试</dt><dd id="evidence-hold-retry">--</dd></div>
<div><dt>PLC 尝试</dt><dd id="evidence-plc">--</dd></div>
</dl>
<div class="path-proof">
<span>调度中心 plannedPath</span>
<output id="planned-path-output">--</output>
</div>
<div class="reason-proof">
<span>终态 / 当前原因</span>
<output id="reason-output">--</output>
</div>
</aside>
</main>
<section id="timeline-panel" class="timeline-panel" aria-labelledby="timeline-title">
<div class="timeline-controls">
<div>
<span class="section-kicker">逐 tick 证据</span>
<h2 id="timeline-title">调度时间线</h2>
</div>
<div class="playback-buttons" role="group" aria-label="时间线播放控制">
<button id="step-back" type="button" class="icon-button" title="上一个事件" aria-label="上一个事件">◀</button>
<button id="play-timeline" type="button" class="icon-button" title="播放" aria-label="播放">▶</button>
<button id="pause-timeline" type="button" class="icon-button" title="暂停" aria-label="暂停">Ⅱ</button>
<button id="step-forward" type="button" class="icon-button" title="下一个事件" aria-label="下一个事件">▶</button>
</div>
<span id="tick-readout" class="tick-readout">tick --</span>
<input id="timeline-slider" type="range" min="0" max="0" value="0" aria-label="调度 tick">
<label class="inline-field speed-field"><span>速度</span><select id="playback-speed"><option value="900">0.5×</option><option value="450" selected>1×</option><option value="220">2×</option><option value="90">5×</option></select></label>
</div>
<div class="table-toolbar timeline-filter-bar">
<select id="event-type-filter" aria-label="按事件类型筛选"><option value="ALL">全部事件</option></select>
<span id="event-count" class="table-count">0 个事件</span>
</div>
<div class="table-scroll event-table-scroll">
<table class="data-table event-table">
<thead><tr><th>tick</th><th>事件</th><th>车辆</th><th>原因</th><th>移动</th></tr></thead>
<tbody id="event-table-body"></tbody>
</table>
</div>
</section>
<section id="safety-view" class="safety-view" hidden>
<div class="safety-header">
<div><span class="section-kicker">验收门禁</span><h2>资源安全与幂等</h2></div>
<button id="export-safety" type="button" class="secondary-button" disabled>导出证据</button>
</div>
<div id="safety-metrics" class="audit-grid"></div>
<div class="reason-columns">
<section><h3>HOLD 原因</h3><div id="hold-reasons" class="reason-list"></div></section>
<section><h3>PLC 重试原因</h3><div id="retry-reasons" class="reason-list"></div></section>
<section><h3>失败原因</h3><div id="failure-reasons" class="reason-list"></div></section>
</div>
</section>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
</div>
<script src="/assets/app.js" defer></script>
</body>
</html>

View File

@@ -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;
}
}
}