feat(javaTest): add dispatch audit visualizer
This commit is contained in:
@@ -96,6 +96,28 @@ mvn compile exec:java \
|
|||||||
> 历史表不含独立的仿真起始时刻车辆位置/状态快照,因此车辆 `1/2/3` 分别从完整地图中的
|
> 历史表不含独立的仿真起始时刻车辆位置/状态快照,因此车辆 `1/2/3` 分别从完整地图中的
|
||||||
> 固定站点 `010701/010702/010703` 初始化;历史 `agv_id` 仅用于对照,不参与初始位置或选车约束。
|
> 固定站点 `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
|
```bash
|
||||||
|
|||||||
@@ -110,6 +110,22 @@ public final class HistoricalDispatchVisualizerServer {
|
|||||||
private void route(HttpExchange exchange) throws IOException {
|
private void route(HttpExchange exchange) throws IOException {
|
||||||
String path = exchange.getRequestURI().getPath();
|
String path = exchange.getRequestURI().getPath();
|
||||||
String method = exchange.getRequestMethod();
|
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)) {
|
if ("/api/health".equals(path)) {
|
||||||
requireMethod(method, "GET");
|
requireMethod(method, "GET");
|
||||||
Map<String, Object> health = new LinkedHashMap<String, Object>();
|
Map<String, Object> health = new LinkedHashMap<String, Object>();
|
||||||
@@ -146,6 +162,40 @@ public final class HistoricalDispatchVisualizerServer {
|
|||||||
throw new VisualizerHttpException(404, "NOT_FOUND", "请求的审计资源不存在");
|
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) {
|
private Map<String, Object> runSimulation(SimulationCall call) {
|
||||||
if (!simulationBusy.compareAndSet(false, true)) {
|
if (!simulationBusy.compareAndSet(false, true)) {
|
||||||
throw new VisualizerHttpException(409, "SIMULATION_BUSY", "已有仿真正在运行");
|
throw new VisualizerHttpException(409, "SIMULATION_BUSY", "已有仿真正在运行");
|
||||||
|
|||||||
409
javaTest/src/main/resources/visualizer/assets/app.css
Normal file
409
javaTest/src/main/resources/visualizer/assets/app.css
Normal 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; }
|
||||||
|
}
|
||||||
849
javaTest/src/main/resources/visualizer/assets/app.js
Normal file
849
javaTest/src/main/resources/visualizer/assets/app.js
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}());
|
||||||
219
javaTest/src/main/resources/visualizer/index.html
Normal file
219
javaTest/src/main/resources/visualizer/index.html
Normal 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>
|
||||||
@@ -92,6 +92,38 @@ class HistoricalDispatchVisualizerServerTest {
|
|||||||
assertEquals(0, map(map(response.body).get("invariants")).get("totalSafetyViolations"));
|
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
|
@Test
|
||||||
void rejectsUnknownRoutesWrongMethodsMalformedAndOversizedBodies() throws Exception {
|
void rejectsUnknownRoutesWrongMethodsMalformedAndOversizedBodies() throws Exception {
|
||||||
HttpResult unknown = request("GET", "/api/unknown", null);
|
HttpResult unknown = request("GET", "/api/unknown", null);
|
||||||
@@ -129,11 +161,13 @@ class HistoricalDispatchVisualizerServerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
int status = connection.getResponseCode();
|
int status = connection.getResponseCode();
|
||||||
|
String contentType = connection.getHeaderField("Content-Type");
|
||||||
|
String contentSecurityPolicy = connection.getHeaderField("Content-Security-Policy");
|
||||||
InputStream stream = status >= 400
|
InputStream stream = status >= 400
|
||||||
? connection.getErrorStream() : connection.getInputStream();
|
? connection.getErrorStream() : connection.getInputStream();
|
||||||
String response = stream == null ? "" : read(stream);
|
String response = stream == null ? "" : read(stream);
|
||||||
connection.disconnect();
|
connection.disconnect();
|
||||||
return new HttpResult(status, response);
|
return new HttpResult(status, response, contentType, contentSecurityPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String read(InputStream input) throws IOException {
|
private String read(InputStream input) throws IOException {
|
||||||
@@ -162,10 +196,15 @@ class HistoricalDispatchVisualizerServerTest {
|
|||||||
private static final class HttpResult {
|
private static final class HttpResult {
|
||||||
private final int status;
|
private final int status;
|
||||||
private final String body;
|
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.status = status;
|
||||||
this.body = body;
|
this.body = body;
|
||||||
|
this.contentType = contentType;
|
||||||
|
this.contentSecurityPolicy = contentSecurityPolicy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user