feat: integrate calibration extension and self-repair modules

This commit is contained in:
vee
2026-08-07 23:39:45 +08:00
parent 156b10fda1
commit 3bceb0d5a1
41 changed files with 21310 additions and 314 deletions
+52
View File
@@ -4,6 +4,9 @@ Web 控制台提供一句话、快捷功能模式与手势控制入口,不在
当前页面包括:
- 顶部“自编排 / 自标定 / 自扩展 / 自修复”模块切换;user 进入自标定或自修复时自动切换为 admin,自修复停留期间不能降级为 user;自扩展包含外设学习与统一控制两种工作模式。
- 六类外设玩具的添加、红外能力学习、测试发射、单动作执行与本地能力目录。
- 基于已学习外设能力和车端能力的 GPT 联动计划,车端步骤复用现有安全编排链路。
- 一句话联动座椅、空调、天幕和氛围灯,支持 Enter 执行和 GPT/本地规则切换。
- 观影、午休、零重力快捷模式,以及当前副驾驶姿态的命名保存入口。
- 六阶段执行链路、整车联合孪生、领域状态、配置、CAN 计划和执行结果。
@@ -39,6 +42,55 @@ make dev-lan
其他设备访问启动日志打印的 `http://<LAN-IP>:8000`。页面与后端 API 由同一服务提供,无需单独配置前端地址。
自扩展接口统一位于 `/api/extension/*`,GSCU1B 默认以 TCP 模式监听
`0.0.0.0:4444`。可通过 `GSCU_TRANSPORT=simulation` 使用演示学习器,或通过
`GSCU_TRANSPORT=mqtt` 与 `GSCU_MQTT_*` 配置接入 MQTT。MQTT 依赖可通过
项目的 `ir` 可选依赖安装。自扩展不再需要独立的 `8010` 前端服务。
## N7 自标定
自标定前端、原始 Android `n7_seat.glb`、四轴 Hall 标定协议以及
CAN/MQTT/RTSP 后端均由当前服务同源提供,不再启动原 `8766` 工作站或使用
iframe。左侧“执行目标”复用自编排下拉控件,默认显示“真实台架”,也可以
切换到“标定仿真台架”。选择目标后仍需显式点击连接,服务启动不会自动打开
真实 CAN 适配器。
标定 API 位于 `/api/calibration/*`,包含状态流、CAN 连接、四轴/单轴标定、
绝对位置、端点、全轴 STOP、MQTT、RTSP 和连接设置。仿真台架实现同一套
CRC、ACK、ARM、心跳租约和 STOP 协议边界,并生成双目位姿数据;它与自编排
的 `dry_run`、`simulated_hardware` 执行模式相互独立。真实 ZLG CAN 连接复用
仓库现有的 ZCANPRO 32 位 bridge,实机运动接口另外限制为台架本机访问。
MQTT 与通用 CAN 依赖可安装为:
```powershell
python -m pip install -e ".[can,ir]"
```
RTSP 画面需要将 `ffmpeg.exe` 加入 `PATH`;未安装 FFmpeg 时,CAN、MQTT、
标定和 3D 数字孪生不受影响。
## 座椅自修复
原 `manual_hil` 工程中的六项量产软件故障闭环、硬件支路诊断、72 针连接器
定义(其中 21 针启用)、ZCANPRO bridge 以及固件资格检查已迁入当前服务,
统一使用 `/api/self-repair/*`,不再启动独立前端或 HTTP 服务。自修复入口始终
显示;user 点击入口后自动切换为 admin,停留在自修复期间权限选择器锁定。
后端对所有自修复 API 再次执行 admin 校验,实物诊断、实物故障注入、归零、
STOP ALL、日志目录和固件操作还要求台架本机决策点。
软件自修复默认使用 `shadow`,仅推进内存中的故障注入、AI 诊断、恢复和复测
状态机,不会写入 ECU。页面左侧显示六项量产故障用例及总体进度,中间程序化
3D 座椅同步四轴与热管理状态,右侧五节点以深蓝、浅蓝、灰色分别表示已完成、
进行中和待执行。硬件视图提供 CAN 分层状态、功能支路、JA1/JB1/JC1 物理针脚
排布与证据详情。
所有机器相关路径均通过环境变量配置,完整清单及示例见
[`config/self-repair/README.md`](../../config/self-repair/README.md)。未配置固件、
IAR 或 ZCANPRO 路径时,对应实物操作保持禁用;服务导入与启动不会自动拉起
ZCANPRO。只有显式设置 `SELF_REPAIR_AUTO_START_BRIDGE=true`,或管理员启动
需要 bridge 的实物操作时,服务才会尝试运行配置的扩展脚本。
公网入口由后端统一认证,前端不保存密码或 API Key。未认证访问自动进入
`/login`,主控制台提供退出按钮。固定入口是
`https://511.kendeji.fun`,应用容器只监听服务器回环地址,再由 Caddy 提供
-71
View File
@@ -19,11 +19,6 @@ function loadHardwareClientId() {
return clientId;
}
function defaultHardwareClientLabel(clientId) {
return localStorage.getItem("cognitive-agent.hardware-client-label")
|| `测试终端-${clientId.slice(-6)}`;
}
const state = {
provider: "openai",
executionMode: "dry_run",
@@ -3395,32 +3390,6 @@ function isLocalDecisionHost() {
function renderHardwareAccess(status) {
state.hardwareAccess = status;
const statusNode = $("#hardwareAccessStatus");
const requestButton = $("#requestHardwareAccess");
const releaseButton = $("#releaseHardwareAccess");
if (!statusNode || !requestButton || !releaseButton) return;
const accessState = status?.state || "none";
statusNode.dataset.state = accessState;
if (accessState === "approved") {
statusNode.textContent = `本地已同意。剩余空闲时间 ${status.idle_remaining_seconds ?? 180} 秒;每次真实执行后重新计时。`;
requestButton.hidden = true;
releaseButton.hidden = false;
} else if (accessState === "pending") {
statusNode.textContent = `正在等待本地审批,当前排队第 ${status.queue_position || 1} 位;未获批前不会进入 CAN 链路。`;
requestButton.textContent = "更新申请";
requestButton.hidden = false;
releaseButton.hidden = false;
} else if (accessState === "expired") {
statusNode.textContent = "因 3 分钟未执行测试,使用权已自动停止;再次使用请重新申请。";
requestButton.textContent = "重新申请";
requestButton.hidden = false;
releaseButton.hidden = true;
} else {
statusNode.textContent = "尚未申请;真实台架指令不会进入 CAN 链路。";
requestButton.textContent = "向本地申请";
requestButton.hidden = false;
releaseButton.hidden = true;
}
}
async function loadHardwareAccessStatus({ silent = false } = {}) {
@@ -3436,42 +3405,6 @@ async function loadHardwareAccessStatus({ silent = false } = {}) {
}
}
async function requestHardwareAccess() {
const input = $("#hardwareClientLabel");
const label = input.value.trim() || defaultHardwareClientLabel(state.hardwareClientId);
input.value = label;
localStorage.setItem("cognitive-agent.hardware-client-label", label);
try {
const response = await apiRequest("/api/hardware/access/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: state.hardwareClientId, label }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.message || payload.error);
renderHardwareAccess(payload);
showToast(payload.state === "approved" ? "当前使用权仍然有效" : "申请已发送到台架本机,请等待审批");
} catch (error) {
showToast(`申请失败:${error.message}`);
}
}
async function releaseHardwareAccess() {
try {
const response = await apiRequest("/api/hardware/access/release", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: state.hardwareClientId }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.message || payload.error);
renderHardwareAccess(payload);
showToast("已停止使用真实台架;再次使用需要重新申请");
} catch (error) {
showToast(`停止使用失败:${error.message}`);
}
}
function selectProvider(provider) {
state.provider = provider;
$$(".segment[data-provider]").forEach((button) => {
@@ -3514,7 +3447,6 @@ function selectExecutionMode(mode) {
const simulatedHardware = mode === "simulated_hardware";
const approvalRequired = Boolean(state.runtime?.hardware?.approval_required);
$("#approvalField").hidden = !hardware || !approvalRequired;
$("#hardwareAccessField").hidden = !hardware;
$("#simulationFaultField").hidden = !simulatedHardware;
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
$("#executionBadge").innerHTML = hardware
@@ -5001,9 +4933,6 @@ document.addEventListener("DOMContentLoaded", async () => {
refreshIcons();
initSeatTwin();
$("#requestForm").addEventListener("submit", submitRequest);
$("#hardwareClientLabel").value = defaultHardwareClientLabel(state.hardwareClientId);
$("#requestHardwareAccess").addEventListener("click", requestHardwareAccess);
$("#releaseHardwareAccess").addEventListener("click", releaseHardwareAccess);
$("#utterance").addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
Binary file not shown.
File diff suppressed because one or more lines are too long
+300 -131
View File
@@ -343,8 +343,8 @@
.v2-config-stack {
display: grid;
gap: 12px;
padding: 2px 0 0 10px;
gap: 8px;
padding: 0 0 0 9px;
border-left: 1px solid color-mix(in srgb, var(--v2-accent) 14%, transparent);
}
@@ -353,55 +353,70 @@
margin: 0;
}
.console-v2 .v2-config-field .provider-note {
margin: 5px 3px 0;
color: #7a8a8a;
font-size: 8px;
line-height: 1.35;
.console-v2 .v2-config-field > .provider-note,
.console-v2 .v2-config-card > .provider-note,
.console-v2 .v2-config-card > .v2-gesture-model-note {
display: none;
}
.console-v2 .v2-gesture-model-note {
display: block;
margin: 5px 3px 0;
color: #7a8a8a;
font-size: 8px;
line-height: 1.35;
}
.console-v2 .v2-vehicle-field .vehicle-select-control,
.v2-static-select,
.v2-select-shell {
min-height: 46px;
.console-v2 .v2-config-card {
min-width: 0;
min-height: 43px;
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 0 13px;
color: #0d2527;
background: color-mix(in srgb, var(--v2-accent-soft) 72%, white);
border: 1px solid color-mix(in srgb, var(--v2-accent) 17%, white);
padding: 6px 8px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 10px;
}
.console-v2 .v2-vehicle-field .vehicle-select-control > svg,
.v2-static-select > svg,
.v2-select-shell > svg {
.console-v2 .v2-config-card > svg {
width: 17px;
height: 17px;
color: var(--v2-accent);
}
.console-v2 .v2-vehicle-field select,
.v2-select-shell select {
.v2-config-card-copy,
.v2-config-card > div {
min-width: 0;
}
.v2-config-card small,
.v2-config-card strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-config-card small {
color: #819091;
font-size: 8px;
}
.v2-config-card strong {
margin-top: 2px;
color: #263d3f;
font-size: 11px;
}
.v2-config-card-select {
min-width: 0;
position: relative;
}
.v2-config-card-select select {
width: 100%;
height: 44px;
height: 18px;
padding: 0;
appearance: none;
color: #102224;
color: #263d3f;
background: transparent;
border: 0;
outline: 0;
font-size: 14px;
font-size: 11px;
font-weight: 850;
cursor: pointer;
}
@@ -443,12 +458,10 @@
cursor: pointer;
}
.vehicle-select-control > .v2-dropdown-trigger,
.v2-select-shell > .v2-dropdown-trigger {
.v2-config-card-select > .v2-dropdown-trigger {
width: 100%;
height: 44px;
grid-column: 2 / -1;
font-size: 14px;
height: 18px;
font-size: 11px;
font-weight: 850;
text-align: left;
}
@@ -567,25 +580,30 @@
opacity: 0.72;
}
.v2-static-select strong {
min-width: 0;
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-field-state {
padding: 4px 6px;
padding: 3px 5px;
color: var(--v2-accent-strong);
background: rgba(255, 255, 255, 0.68);
background: color-mix(in srgb, var(--v2-accent-soft) 68%, white);
border-radius: 5px;
font: 850 8px "SFMono-Regular", Consolas, monospace;
font: 800 7px "SFMono-Regular", Consolas, monospace;
}
.v2-static-select.unavailable {
.v2-config-card.unavailable {
color: #9b5f32;
background: #fff1df;
}
.v2-config-card.unavailable > svg,
.v2-config-card.unavailable > .v2-field-state {
color: #9b5f32;
}
.v2-config-card[data-state="offline"] > svg,
.v2-config-card[data-state="offline"] > .v2-field-state {
color: #a85f43;
}
.v2-config-card[data-state="offline"] > .v2-field-state {
background: #fff0e9;
}
.console-v2[data-demo-role="user"] .v2-execution-field {
@@ -723,12 +741,13 @@
/* Main canvas -------------------------------------------------------------- */
.console-v2 .main-panel {
--v2-main-pad: clamp(12px, 1.5vw, 20px);
min-width: 0;
height: 100dvh;
display: grid;
grid-template-rows: clamp(240px, 31.5dvh, 340px) minmax(0, 1fr);
grid-template-rows: 36px clamp(240px, 31.5dvh, 340px) minmax(0, 1fr);
gap: clamp(10px, 1.25vh, 14px);
padding: clamp(12px, 1.5vw, 20px);
padding: 0 var(--v2-main-pad) var(--v2-main-pad);
overflow: hidden;
background:
radial-gradient(circle at 40% -15%, rgba(255, 255, 255, 0.98), transparent 38%),
@@ -1093,11 +1112,12 @@
position: static;
display: grid;
grid-template-columns:
minmax(205px, 0.72fr)
minmax(330px, 1.12fr)
minmax(282px, 0.9fr);
grid-template-rows: auto auto minmax(0, 1fr);
gap: 9px 12px;
minmax(180px, 0.72fr)
44px
minmax(270px, 1.58fr)
minmax(230px, 1fr);
grid-template-rows: minmax(0, 1fr);
gap: 12px;
padding: 0;
overflow: hidden;
visibility: visible;
@@ -1117,47 +1137,114 @@
}
.console-v2 .v2-inline-gesture .gesture-camera-stage {
width: 100%;
min-width: 0;
grid-column: 1;
grid-row: 1 / -1;
grid-row: 1;
height: 100%;
min-height: 0;
margin: 0;
aspect-ratio: auto;
overflow: hidden;
background: linear-gradient(145deg, #263537, #111b1d);
border: 1px solid rgba(43, 103, 102, 0.18);
border: 1px solid color-mix(in srgb, var(--v2-accent) 42%, transparent);
border-radius: 17px;
box-shadow: 0 12px 32px rgba(25, 48, 50, 0.15);
box-shadow: 0 12px 32px color-mix(in srgb, var(--v2-accent) 14%, transparent);
}
.v2-module-switcher {
min-width: 0;
min-height: 36px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: stretch;
margin-inline: calc(0px - var(--v2-main-pad));
padding: 3px var(--v2-main-pad) 0;
overflow: hidden;
background: color-mix(in srgb, var(--v2-accent) 30%, var(--v2-accent-soft));
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--v2-accent) 28%, transparent);
border-radius: 0;
box-shadow: 0 6px 16px color-mix(in srgb, var(--v2-accent) 10%, transparent);
}
.v2-module-tab {
min-width: 0;
min-height: 32px;
position: relative;
display: grid;
place-items: center;
padding: 5px 10px 7px;
overflow: hidden;
color: color-mix(in srgb, var(--v2-accent-strong) 48%, #7c898a);
background: transparent;
border: 0;
border-radius: 0;
font: 650 clamp(11px, 0.92vw, 14px) Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
text-overflow: ellipsis;
transition: color 160ms ease, font-weight 160ms ease;
white-space: nowrap;
}
.v2-module-tab > span {
overflow: hidden;
text-overflow: ellipsis;
}
.v2-module-tab.active {
color: var(--v2-accent-strong);
background: transparent;
box-shadow: none;
font-weight: 800;
}
.v2-module-tab.active::after {
width: clamp(48px, 45%, 72px);
height: 3px;
position: absolute;
right: auto;
bottom: 0;
left: 50%;
content: "";
background: var(--v2-accent);
border-radius: 999px;
transform: translateX(-50%);
}
.v2-module-tab:disabled {
cursor: default;
opacity: 0.78;
}
.console-v2 .v2-inline-gesture .v2-camera-toggle {
width: 42px;
height: 42px;
min-height: 42px;
position: absolute;
right: 13px;
bottom: 12px;
z-index: 5;
grid-row: 3;
position: static;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: #083a35;
border: 1px solid rgba(255, 255, 255, 0.56);
color: white;
border: 0;
border-radius: 50%;
background: linear-gradient(135deg, #a8f2e2, #58d2b9);
box-shadow: 0 8px 20px rgba(4, 27, 27, 0.3);
background: var(--v2-accent);
box-shadow: 0 8px 20px color-mix(in srgb, var(--v2-accent) 24%, transparent);
cursor: pointer;
transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.console-v2 .v2-inline-gesture .v2-camera-toggle:hover {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(4, 27, 27, 0.38);
background: var(--v2-accent-strong);
box-shadow: 0 10px 24px color-mix(in srgb, var(--v2-accent) 32%, transparent);
}
.console-v2 .v2-inline-gesture .v2-camera-toggle:disabled {
cursor: wait;
opacity: 0.64;
cursor: not-allowed;
opacity: 1;
}
.console-v2 .v2-inline-gesture .v2-camera-toggle svg {
@@ -1176,9 +1263,8 @@
}
.console-v2 .v2-inline-gesture .v2-camera-toggle.active {
color: #fff4f4;
border-color: rgba(255, 255, 255, 0.34);
background: linear-gradient(135deg, #e26969, #bb3f48);
color: white;
background: var(--v2-accent);
}
.console-v2 .v2-inline-gesture .v2-camera-toggle.active svg {
@@ -1193,56 +1279,70 @@
background: currentColor;
}
.console-v2 .v2-inline-gesture .gesture-status-strip {
.console-v2 .v2-inline-gesture .v2-gesture-action-rail {
grid-column: 2;
grid-row: 1;
min-height: 36px;
margin: 0;
padding: 7px 10px;
background: #f1f5f4;
border: 1px solid #e2eae8;
border-radius: 9px;
}
.console-v2 .v2-inline-gesture .gesture-now {
grid-column: 2;
grid-row: 2;
min-height: 50px;
margin: 0;
padding: 8px 10px;
background: color-mix(in srgb, var(--v2-accent-soft) 44%, white);
border: 1px solid var(--v2-line);
border-radius: 10px;
}
.console-v2 .v2-inline-gesture .gesture-actions {
grid-column: 2;
grid-row: 3;
align-self: end;
align-self: stretch;
position: relative;
z-index: 8;
display: grid;
grid-template-rows: 42px 10px 42px;
align-content: end;
place-items: center;
gap: 0;
margin: 0;
}
.console-v2 .v2-inline-gesture #gestureStopButton,
.console-v2[data-demo-role="user"] .v2-inline-gesture .gesture-actions {
.console-v2 .v2-inline-gesture #gestureStopButton {
display: none;
}
.console-v2[data-demo-role="admin"] .v2-inline-gesture .gesture-actions {
position: relative;
z-index: 8;
display: flex;
justify-content: flex-end;
.console-v2 .v2-inline-gesture #gestureArmButton {
width: 42px;
height: 42px;
min-height: 42px;
grid-row: 1;
display: inline-grid;
place-items: center;
padding: 0;
overflow: hidden;
color: white;
border: 0;
border-radius: 50%;
background: var(--v2-accent);
box-shadow: 0 8px 20px color-mix(in srgb, var(--v2-accent) 24%, transparent);
font-size: 0;
line-height: 0;
}
.console-v2[data-demo-role="admin"] .v2-inline-gesture #gestureArmButton {
width: min(150px, 100%);
.console-v2 .v2-inline-gesture #gestureArmButton svg {
width: 17px;
height: 17px;
display: block;
margin: 0;
}
.console-v2 .v2-inline-gesture #gestureArmButton span {
display: none;
}
.console-v2[data-demo-role="user"] .v2-inline-gesture #gestureArmButton {
cursor: default;
opacity: 1;
}
.console-v2[data-demo-role="user"] .v2-inline-gesture #gestureArmButton svg {
display: none;
}
.console-v2 .v2-inline-gesture .gesture-debug-panel {
width: min(360px, 100%);
width: clamp(300px, 28vw, 360px);
max-width: none;
position: absolute;
right: 0;
bottom: calc(100% + 7px);
top: auto;
left: calc(100% + 8px);
right: auto;
bottom: 52px;
display: grid;
gap: 7px;
padding: 9px;
@@ -1297,9 +1397,14 @@
}
.console-v2 .v2-inline-gesture .gesture-debug-controls button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 5px 9px;
color: white;
background: var(--v2-accent);
white-space: nowrap;
}
.console-v2 .v2-inline-gesture .gesture-debug-controls button svg {
@@ -1307,21 +1412,91 @@
height: 12px;
}
.console-v2 .v2-inline-gesture .gesture-actions button {
min-height: 38px;
border-radius: 9px;
}
.console-v2 .v2-inline-gesture .gesture-footnote,
.console-v2 .v2-inline-gesture .gesture-progress {
display: none;
}
.console-v2 .v2-inline-gesture .gesture-guide-card {
.console-v2 .v2-inline-gesture .v2-gesture-result-stack {
min-width: 0;
min-height: 0;
grid-column: 3;
grid-row: 1 / -1;
grid-row: 1;
display: grid;
grid-template-rows: minmax(58px, 0.78fr) minmax(72px, 1.22fr);
gap: 9px;
}
.console-v2 .v2-inline-gesture .v2-gesture-recognition-card,
.console-v2 .v2-inline-gesture .v2-gesture-execution-slot {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 4px;
padding: 8px 10px;
overflow: hidden;
border: 1px solid var(--v2-line);
border-radius: 11px;
background: color-mix(in srgb, var(--v2-accent-soft) 24%, #f1f3f3);
}
.console-v2 .v2-inline-gesture .v2-gesture-execution-slot {
background: color-mix(in srgb, var(--v2-accent-soft) 16%, #f4f5f5);
}
.console-v2 .v2-inline-gesture .v2-gesture-result-label {
color: color-mix(in srgb, var(--v2-accent-strong) 68%, #5f6d6e);
font-size: 10px;
font-weight: 850;
line-height: 1.2;
}
.console-v2 .v2-inline-gesture .gesture-status-strip {
display: none;
}
.console-v2 .v2-inline-gesture .gesture-now {
min-height: 0;
margin: 0;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.66);
border: 1px solid color-mix(in srgb, var(--v2-accent) 8%, transparent);
border-radius: 8px;
}
.console-v2 .v2-inline-gesture .v2-gesture-execution-slot .v2-request-status {
width: 100%;
height: 100%;
min-height: 0;
align-content: center;
gap: 4px;
padding: 2px 8px 0;
background: transparent;
border: 0;
border-radius: 0;
}
.console-v2 .v2-inline-gesture .v2-gesture-execution-slot .v2-request-status .request-title-row {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.console-v2 .v2-inline-gesture .v2-gesture-execution-slot .v2-request-status h2 {
color: var(--v2-ink);
font-size: 11px;
}
.console-v2 .v2-inline-gesture .v2-gesture-execution-slot .v2-request-status #requestMeta {
color: var(--v2-muted);
}
.console-v2 .v2-inline-gesture .gesture-guide-card {
min-width: 0;
min-height: 0;
grid-column: 4;
grid-row: 1;
align-self: stretch;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
@@ -2178,16 +2353,8 @@
padding-left: 8px;
}
.console-v2 .v2-vehicle-field .vehicle-select-control,
.v2-static-select,
.v2-select-shell {
min-height: 39px;
}
.console-v2 .v2-vehicle-field select,
.v2-select-shell select {
height: 37px;
font-size: 12px;
.console-v2 .v2-config-card {
min-height: 38px;
}
.console-v2 .v2-config-field .provider-note {
@@ -2195,9 +2362,10 @@
}
.console-v2 .main-panel {
grid-template-rows: clamp(207px, 29dvh, 236px) minmax(0, 1fr);
--v2-main-pad: 9px;
grid-template-rows: 34px clamp(207px, 29dvh, 236px) minmax(0, 1fr);
gap: 12px;
padding: 9px;
padding: 0 var(--v2-main-pad) var(--v2-main-pad);
}
.v2-natural-panel {
@@ -2341,9 +2509,10 @@
}
.console-v2 .main-panel {
grid-template-rows: 350px minmax(0, 1fr);
--v2-main-pad: 24px;
grid-template-rows: 42px 350px minmax(0, 1fr);
gap: 18px;
padding: 24px;
padding: 0 var(--v2-main-pad) var(--v2-main-pad);
}
.v2-operation-shell,
+236 -27
View File
@@ -11,12 +11,23 @@
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const initialRole = sessionStorage.getItem("cognitive-agent.demo-role") === "admin"
? "admin"
: "user";
const storedModule = sessionStorage.getItem("cognitive-agent.active-module");
const ui = {
role: sessionStorage.getItem("cognitive-agent.demo-role") === "admin" ? "admin" : "user",
role: initialRole,
controlMode: sessionStorage.getItem("cognitive-agent.control-mode") === "gesture"
? "gesture"
: "natural",
activeModule: storedModule === "self-extension"
? "self-extension"
: storedModule === "self-calibration"
? "self-calibration"
: storedModule === "self-repair"
? "self-repair"
: "self-orchestration",
activePage: 0,
sidebarCollapsed: sessionStorage.getItem("cognitive-agent.sidebar-collapsed") === "true",
};
@@ -159,6 +170,9 @@
<option value="admin">admin</option>
</select>
</div>
<div class="v2-extension-profile-title" aria-label="自扩展面向所有登录用户">
<span>Hi,</span><strong>user</strong>
</div>
</div>
<button class="v2-sidebar-toggle" id="v2SidebarToggle" type="button" aria-label="折叠左侧控制台" aria-expanded="true">
<i data-lucide="panel-left-close"></i>
@@ -177,7 +191,7 @@
const config = make("section", "v2-sidebar-section v2-config-section", `
<div class="v2-divider"></div>
<span class="v2-field-caption">配置中心</span>
<span class="v2-field-caption">配置信息</span>
<div class="v2-config-stack" id="v2ConfigStack"></div>`);
const footer = make("footer", "v2-sidebar-footer", `
@@ -194,33 +208,44 @@
const vehicleField = $(".vehicle-field", originalForm);
const providerField = $(".field-group:has(.segmented [data-provider])", originalForm);
const executionField = $(".field-group:has(.execution-mode-options)", originalForm);
const hardwareAccessField = $("#hardwareAccessField", originalForm);
const simulationFaultField = $("#simulationFaultField", originalForm);
const approvalField = $("#approvalField", originalForm);
if (vehicleField) {
vehicleField.classList.add("v2-config-field", "v2-vehicle-field");
const vehicleControl = $(".vehicle-select-control", vehicleField);
const vehicleSelect = $("#vehicleSelect", vehicleField);
const vehicleIcon = $("svg, [data-lucide]", vehicleControl);
if (vehicleControl && vehicleSelect && vehicleIcon) {
const copy = make("div", "v2-config-card-copy", "<small>车型配置</small>");
const selectSlot = make("div", "v2-config-card-select", "");
const state = make("span", "v2-field-state", "CHECK");
state.id = "v2VehicleState";
selectSlot.append(vehicleSelect);
copy.append(selectSlot);
vehicleControl.classList.add("v2-config-card", "v2-config-select-card");
vehicleControl.id = "v2VehicleCard";
vehicleControl.dataset.state = "checking";
vehicleControl.replaceChildren(vehicleIcon, copy, state);
$(".field-label", vehicleField)?.remove();
}
configStack.append(vehicleField);
}
const modelField = make("div", "v2-config-field v2-model-field", `
<span class="field-label">推理模型</span>
<div class="v2-static-select" id="v2ModelField">
<i data-lucide="sparkles"></i>
<strong id="v2ModelValue">模型检测中</strong>
<span class="v2-field-state" id="v2ModelState">AUTO</span>
</div>
const modelField = make("div", "v2-config-field v2-config-card v2-model-field", `
<i data-lucide="sparkles"></i>
<div><small>推理模型</small><strong id="v2ModelValue">模型检测中</strong></div>
<span class="v2-field-state" id="v2ModelState">AUTO</span>
<p class="provider-note" id="v2ModelNote">模型由服务端安全配置</p>`);
modelField.id = "v2ModelField";
configStack.append(modelField);
const gestureModelField = make("div", "v2-config-field v2-gesture-model-field", `
<span class="field-label">手势模型</span>
<div class="v2-static-select" id="v2GestureModelField">
<i data-lucide="scan-line"></i>
<strong id="v2GestureModelValue">MediaPipe GR</strong>
<span class="v2-field-state">VISION</span>
</div>
const gestureModelField = make("div", "v2-config-field v2-config-card v2-gesture-model-field", `
<i data-lucide="scan-line"></i>
<div><small>手势模型</small><strong id="v2GestureModelValue">MediaPipe GR</strong></div>
<span class="v2-field-state">VISION</span>
<small class="v2-gesture-model-note" id="v2GestureModelNote">Tasks Vision · 0.10.14</small>`);
gestureModelField.id = "v2GestureModelField";
configStack.append(gestureModelField);
const userHardwareState = make("div", "v2-user-target-status", `
@@ -234,14 +259,16 @@
}
const executionSelect = make("div", "v2-config-field v2-execution-field", `
<label class="field-label" for="v2ExecutionSelect">执行目标</label>
<div class="v2-select-shell">
<div class="v2-config-card v2-config-select-card">
<i data-lucide="workflow"></i>
<select id="v2ExecutionSelect" aria-label="选择执行目标">
<option value="dry_run">虚拟 CAN</option>
<option value="simulated_hardware">模拟台架</option>
<option value="hardware">真实台架</option>
</select>
<div class="v2-config-card-copy"><small>执行目标</small><div class="v2-config-card-select">
<select id="v2ExecutionSelect" aria-label="选择执行目标">
<option value="dry_run">虚拟 CAN</option>
<option value="simulated_hardware">模拟台架</option>
<option value="hardware">真实台架</option>
</select>
</div></div>
<span class="v2-field-state">ADMIN</span>
</div>
<p class="provider-note" id="v2ExecutionNote">管理员可切换执行环境</p>`);
configStack.append(executionSelect);
@@ -253,7 +280,7 @@
}
const advanced = make("div", "v2-advanced-stack", "");
[hardwareAccessField, simulationFaultField, approvalField].forEach((field) => {
[simulationFaultField, approvalField].forEach((field) => {
if (!field) return;
field.classList.add("v2-config-field", "v2-advanced-field");
advanced.append(field);
@@ -281,6 +308,7 @@
}
const naturalGrid = make("div", "v2-natural-grid", "");
naturalGrid.id = "v2NaturalGrid";
originalForm.classList.add("v2-request-form");
naturalGrid.append(originalForm);
if (requestHeader) {
@@ -303,17 +331,81 @@
gestureDrawer.classList.add("v2-inline-gesture");
const cameraStage = $(".gesture-camera-stage", gestureDrawer);
const cameraToggle = $("#gestureStartButton", gestureDrawer);
if (cameraStage && cameraToggle) {
const debugToggle = $("#gestureArmButton", gestureDrawer);
const actionRail = $(".gesture-actions", gestureDrawer);
const statusStrip = $(".gesture-status-strip", gestureDrawer);
const gestureNow = $(".gesture-now", gestureDrawer);
const guideCard = $(".gesture-guide-card", gestureDrawer);
if (cameraToggle) {
cameraToggle.classList.add("v2-camera-toggle");
cameraToggle.setAttribute("aria-label", "开启摄像头");
cameraToggle.setAttribute("title", "开启摄像头");
cameraStage.append(cameraToggle);
}
if (actionRail && cameraToggle && debugToggle) {
actionRail.classList.add("v2-gesture-action-rail");
actionRail.insertBefore(debugToggle, cameraToggle);
actionRail.append(cameraToggle);
}
const resultStack = make("div", "v2-gesture-result-stack", "");
resultStack.id = "v2GestureResultStack";
const recognitionCard = make("section", "v2-gesture-recognition-card", `
<span class="v2-gesture-result-label">手势识别结果</span>`);
const executionSlot = make("section", "v2-gesture-execution-slot", `
<span class="v2-gesture-result-label">执行结果</span>`);
executionSlot.id = "v2GestureExecutionSlot";
if (statusStrip) recognitionCard.append(statusStrip);
if (gestureNow) recognitionCard.append(gestureNow);
resultStack.append(recognitionCard, executionSlot);
if (cameraStage) gestureDrawer.append(cameraStage);
if (actionRail) gestureDrawer.append(actionRail);
gestureDrawer.append(resultStack);
if (guideCard) gestureDrawer.append(guideCard);
gesturePanel.append(gestureDrawer);
}
return operation;
}
function buildModuleSwitcher(mainPanel) {
const modules = [
{ id: "self-orchestration", label: "自编排", available: true },
{
id: "self-calibration",
label: "自标定",
available: true,
adminOnly: true,
},
{ id: "self-extension", label: "自扩展", available: true },
{
id: "self-repair",
label: "自修复",
available: true,
adminOnly: true,
},
];
const switcher = make("nav", "v2-module-switcher", "");
switcher.id = "v2ModuleSwitcher";
switcher.setAttribute("role", "tablist");
switcher.setAttribute("aria-label", "能力模块切换");
modules.forEach((module, index) => {
const button = make("button", "v2-module-tab", `<span>${module.label}</span>`);
button.type = "button";
button.id = `v2ModuleTab${index}`;
button.dataset.module = module.id;
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", "false");
if (module.adminOnly) button.dataset.adminOnly = "true";
if (!module.available) {
button.disabled = true;
button.setAttribute("aria-disabled", "true");
button.title = module.adminOnly ? `${module.label}仅对 admin 开放` : `${module.label}模块待接入`;
}
switcher.append(button);
});
mainPanel.prepend(switcher);
}
function buildOutput(mainPanel) {
const output = make("section", "v2-output-shell", `
<header class="v2-output-heading">
@@ -436,6 +528,13 @@
ui.controlMode = mode === "gesture" ? "gesture" : "natural";
sessionStorage.setItem("cognitive-agent.control-mode", ui.controlMode);
document.body.dataset.controlMode = ui.controlMode;
const requestStatus = $(".v2-request-status");
const statusTarget = ui.controlMode === "gesture"
? $("#v2GestureExecutionSlot")
: $("#v2NaturalGrid");
if (requestStatus && statusTarget && requestStatus.parentElement !== statusTarget) {
statusTarget.append(requestStatus);
}
$$('[data-v2-control]').forEach((button) => {
const active = button.dataset.v2Control === ui.controlMode;
button.classList.toggle("active", active);
@@ -456,7 +555,52 @@
}
}
function setModule(module, { persist = true } = {}) {
if (["self-calibration", "self-repair"].includes(module) && ui.role !== "admin") {
setRole("admin");
}
const allowed = new Set(["self-orchestration", "self-extension"]);
if (ui.role === "admin") {
allowed.add("self-calibration");
allowed.add("self-repair");
}
ui.activeModule = allowed.has(module) ? module : "self-orchestration";
if (persist) {
sessionStorage.setItem("cognitive-agent.active-module", ui.activeModule);
}
document.body.dataset.module = ui.activeModule;
const roleSelect = $("#v2RoleSelect");
if (roleSelect) {
roleSelect.disabled = ui.activeModule === "self-repair";
dropdownControls.get(roleSelect)?.sync();
}
$$('.v2-module-tab').forEach((button) => {
const active = button.dataset.module === ui.activeModule;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
button.tabIndex = active ? 0 : -1;
});
if (["self-extension", "self-calibration", "self-repair"].includes(ui.activeModule)) {
const drawer = $("#gestureDrawer");
if (drawer?.classList.contains("open")) $("#gestureCloseButton")?.click();
} else {
setControlMode(ui.controlMode, { triggerGesture: false });
}
window.dispatchEvent(new CustomEvent("cognitive-agent:module-change", {
detail: { module: ui.activeModule },
}));
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 120);
}
function setRole(role) {
if (ui.activeModule === "self-repair" && role !== "admin") {
const roleSelect = $("#v2RoleSelect");
if (roleSelect) {
roleSelect.value = "admin";
dropdownControls.get(roleSelect)?.sync();
}
return;
}
ui.role = role === "admin" ? "admin" : "user";
sessionStorage.setItem("cognitive-agent.demo-role", ui.role);
document.body.dataset.demoRole = ui.role;
@@ -466,7 +610,27 @@
$("#v2RoleSelect").value = ui.role;
dropdownControls.get($("#v2RoleSelect"))?.sync();
$("#v2PageRail").hidden = ui.role !== "admin";
const calibrationTab = $('.v2-module-tab[data-module="self-calibration"]');
if (calibrationTab) {
calibrationTab.disabled = false;
calibrationTab.setAttribute("aria-disabled", "false");
calibrationTab.title = ui.role === "admin"
? ""
: "进入自标定时将自动切换为 admin";
}
const repairTab = $('.v2-module-tab[data-module="self-repair"]');
if (repairTab) {
repairTab.hidden = false;
repairTab.disabled = false;
repairTab.setAttribute("aria-disabled", "false");
repairTab.title = ui.role === "admin"
? ""
: "进入自修复时将自动切换为 admin";
}
if (ui.role !== "admin") {
if (["self-calibration", "self-repair"].includes(ui.activeModule)) {
setModule("self-orchestration");
}
setPage(0);
requestUserHardwareMode();
} else {
@@ -558,11 +722,42 @@
if (ui.role === "user") requestUserHardwareMode();
}
function setVehicleLinkState(connected) {
const card = $("#v2VehicleCard");
const state = $("#v2VehicleState");
if (!card || !state) return;
card.dataset.state = connected ? "online" : "offline";
state.textContent = connected ? "READY" : "OFFLINE";
}
async function refreshVehicleLinkState() {
const card = $("#v2VehicleCard");
const state = $("#v2VehicleState");
if (card) card.dataset.state = "checking";
if (state) state.textContent = "CHECK";
try {
const response = await fetch("/api/extension/health", {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const health = await response.json();
setVehicleLinkState(Boolean(health.vehicle?.connected));
} catch (_error) {
setVehicleLinkState(false);
}
}
function bindEvents() {
$("#v2RoleSelect")?.addEventListener("change", (event) => setRole(event.target.value));
$$('[data-v2-control]').forEach((button) => {
button.addEventListener("click", () => setControlMode(button.dataset.v2Control));
});
$$('.v2-module-tab').forEach((button) => {
button.addEventListener("click", () => {
if (!button.disabled) setModule(button.dataset.module);
});
});
$("#v2ExecutionSelect")?.addEventListener("change", (event) => {
$(`[data-execution-mode="${event.target.value}"]`)?.click();
syncExecutionSelector();
@@ -571,6 +766,10 @@
button.addEventListener("click", () => setPage(button.dataset.v2PageTarget));
});
$("#v2LogoutButton")?.addEventListener("click", () => $("#logoutButton")?.click());
window.addEventListener("cognitive-agent:connection", (event) => {
if (event.detail?.status === "online") refreshVehicleLinkState();
else if (["offline", "degraded"].includes(event.detail?.status)) setVehicleLinkState(false);
});
$("#v2SidebarToggle")?.addEventListener(
"click",
() => setSidebarCollapsed(!ui.sidebarCollapsed),
@@ -645,6 +844,7 @@
requestHeader,
gestureDrawer,
);
buildModuleSwitcher(mainPanel);
$(".pipeline", mainPanel)?.classList.add("v2-legacy-hidden");
$(".topbar")?.classList.add("v2-legacy-hidden");
@@ -652,10 +852,12 @@
buildOutput(mainPanel);
bindEvents();
setControlMode(ui.controlMode, { triggerGesture: false });
setModule(ui.activeModule, { persist: false });
setRole(ui.role);
setSidebarCollapsed(ui.sidebarCollapsed);
setPage(0);
syncRuntimePresentation();
refreshVehicleLinkState();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
requestAnimationFrame(() => {
updateModeEdgeHints();
@@ -663,5 +865,12 @@
});
}
window.CognitiveAgentConsoleV2 = {
enhanceSelect,
getModule: () => ui.activeModule,
getRole: () => ui.role,
setModule,
};
document.addEventListener("DOMContentLoaded", init);
}());
+26 -1
View File
@@ -689,6 +689,30 @@ function toggleGestureDebug() {
setGestureDebugOpen(panel?.hidden ?? true);
}
function syncGestureDebugRole(role = document.body.dataset.demoRole) {
const button = $("#gestureArmButton");
if (!button) return;
const isAdmin = role === "admin";
if (!isAdmin) setGestureDebugOpen(false);
button.disabled = !isAdmin;
button.classList.toggle("v2-decorative-control", !isAdmin);
if (isAdmin) {
button.removeAttribute("aria-hidden");
button.removeAttribute("tabindex");
button.setAttribute("aria-controls", "gestureDebugPanel");
button.setAttribute("aria-expanded", String(!$("#gestureDebugPanel")?.hidden));
button.setAttribute("aria-label", "手势调试");
button.setAttribute("title", "手势调试");
} else {
button.setAttribute("aria-hidden", "true");
button.setAttribute("tabindex", "-1");
button.removeAttribute("aria-controls");
button.removeAttribute("aria-expanded");
button.removeAttribute("aria-label");
button.removeAttribute("title");
}
}
function sendSimulatedGesture() {
if (document.body.dataset.demoRole !== "admin") {
setGestureDebugOpen(false);
@@ -820,11 +844,12 @@ document.addEventListener("DOMContentLoaded", async () => {
if ($("#gestureDrawer")?.classList.contains("open")) closeDrawer();
});
window.addEventListener("cognitive-agent:demo-role-change", (event) => {
if (event.detail?.role !== "admin") setGestureDebugOpen(false);
syncGestureDebugRole(event.detail?.role);
});
document.addEventListener("visibilitychange", () => { if (document.hidden && state.stream) stopCamera(); });
window.addEventListener("beforeunload", stopCamera);
navigator.mediaDevices?.addEventListener?.("devicechange", handleCameraDeviceChange);
syncGestureDebugRole();
initModeRail();
let runtimeError = null;
try {
+13 -16
View File
@@ -9,17 +9,24 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/app-icon-192.png" />
<title>智舱随行 · 整车智能控制台</title>
<link rel="stylesheet" href="/styles.css?v=android-pad-3" />
<link rel="stylesheet" href="/console-v2.css?v=desktop-refactor-56" />
<link rel="stylesheet" href="/styles.css?v=android-pad-4" />
<link rel="stylesheet" href="/console-v2.css?v=desktop-refactor-68" />
<link rel="stylesheet" href="/self-extension.css?v=extension-9" />
<link rel="stylesheet" href="/self-calibration.css?v=calibration-4" />
<link rel="stylesheet" href="/self-repair.css?v=repair-15" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-1" defer></script>
<script src="/app.js?v=seat-hil-42" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-5"></script>
<script src="/app.js?v=seat-hil-43" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-6"></script>
<script src="/pwa.js?v=android-pad-1" defer></script>
<script src="/console-v2.js?v=desktop-refactor-16" defer></script>
<script src="/console-v2.js?v=desktop-refactor-28" defer></script>
<script src="/self-extension.js?v=extension-6" defer></script>
<script src="/self-calibration.js?v=calibration-2" defer></script>
<script src="/self-repair.js?v=repair-15" defer></script>
<script src="/self-repair-seat3d.js?v=repair-15" defer></script>
</head>
<body>
<header class="topbar">
@@ -108,16 +115,6 @@
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
</div>
<div class="field-group hardware-access-field" id="hardwareAccessField" hidden>
<label class="field-label" for="hardwareClientLabel">真实台架使用权</label>
<input id="hardwareClientLabel" maxlength="64" autocomplete="off" placeholder="终端名称,例如:张三-测试电脑" />
<div class="hardware-access-actions">
<button id="requestHardwareAccess" type="button">向本地申请</button>
<button id="releaseHardwareAccess" type="button" class="secondary" hidden>停止使用</button>
</div>
<p class="provider-note hardware-access-status" id="hardwareAccessStatus">尚未申请;本次真实执行不会进入 CAN 链路。</p>
</div>
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
<label class="field-label" for="simulationFault">故障注入场景</label>
<select id="simulationFault" aria-label="选择模拟硬件故障">
@@ -531,7 +528,7 @@
<div class="gesture-actions">
<button class="gesture-primary" id="gestureStartButton" type="button"><i data-lucide="camera"></i><span>开启摄像头</span></button>
<button id="gestureArmButton" type="button" aria-controls="gestureDebugPanel" aria-expanded="false"><i data-lucide="bug-play"></i>手势调试</button>
<button id="gestureArmButton" type="button" aria-controls="gestureDebugPanel" aria-expanded="false"><i data-lucide="bug-play"></i><span>手势调试</span></button>
<button id="gestureStopButton" type="button" disabled><i data-lucide="octagon"></i>停止</button>
<div class="gesture-debug-panel" id="gestureDebugPanel" hidden>
<div class="gesture-debug-heading"><span>管理员手势调试</span><small>无需开启摄像头</small></div>
File diff suppressed because it is too large Load Diff
+698
View File
@@ -0,0 +1,698 @@
(function () {
"use strict";
const API_ROOT = "/api/calibration";
const TARGET_KEY = "cognitive-agent.calibration-target";
const pageTitles = {
hall: ["N7 SEAT CALIBRATION", "座椅 Hall 标定", "四轴位置、端点与自动标定"],
stereo: ["STEREO CALIBRATION", "双目映射标定", "MQTT 位姿、RTSP 画面与 Hall 映射"],
system: ["CONNECTION SETTINGS", "连接与运行设置", "CAN、MQTT 与 RTSP 接入参数"],
};
const phaseLabels = {
0: "待命",
1: "寻找最近点",
2: "寻找最远点",
3: "返回最近点",
4: "完成",
5: "失败",
6: "已停止",
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
let currentState = null;
let eventSource = null;
let reconnectTimer = 0;
let toastTimer = 0;
let settingsHydrated = false;
let modelRequested = false;
let activeView = "hall";
function buildShell() {
const sidebar = $(".v2-sidebar");
const mainPanel = $(".main-panel");
const moduleSwitcher = $("#v2ModuleSwitcher");
if (!sidebar || !mainPanel || !moduleSwitcher || $("#v2SelfCalibration")) return false;
const calibrationSidebar = document.createElement("section");
calibrationSidebar.className = "v2-sidebar-section v2-calibration-sidebar";
calibrationSidebar.innerHTML = `
<div class="v2-section-heading">
<span>CALIBRATION DESK</span>
<h2>控制台</h2>
</div>
<span class="v2-field-caption">标定方式</span>
<div class="v2-calibration-mode-switch" role="group" aria-label="标定方式">
<button type="button" class="active" data-calibration-view="hall"><i data-lucide="armchair"></i><span>座椅标定</span></button>
<button type="button" data-calibration-view="stereo"><i data-lucide="scan"></i><span>双目标定</span></button>
</div>
<div class="v2-divider"></div>
<span class="v2-field-caption">配置信息</span>
<div class="v2-calibration-config-stack">
<div class="v2-calibration-config-card"><i data-lucide="car-front"></i><div><small>当前车型</small><strong>腾势 N7</strong></div><span>N7</span></div>
<div class="v2-calibration-config-card"><i data-lucide="sparkles"></i><div><small>推理模型</small><strong>GPT-5.6-sol</strong></div><span>READY</span></div>
<div class="v2-calibration-config-card v2-config-select-card" id="calibrationTargetCard">
<i data-lucide="workflow"></i>
<div class="v2-config-card-copy">
<small>执行目标</small>
<div class="v2-config-card-select">
<select id="calibrationExecutionSelect" aria-label="选择自标定执行目标">
<option value="hardware">真实台架</option>
<option value="simulation">标定仿真台架</option>
</select>
</div>
</div>
<span id="calibrationTargetState">IDLE</span>
</div>
</div>
<p class="v2-calibration-target-note" id="calibrationTargetNote">选择执行目标后连接台架</p>
<button class="v2-calibration-connect" id="calibrationConnectTarget" type="button"><i data-lucide="plug-zap"></i><span>连接真实台架</span></button>
<button class="v2-calibration-settings-link" id="calibrationSettingsLink" type="button"><i data-lucide="settings-2"></i><span>连接设置</span><i data-lucide="chevron-right"></i></button>`;
sidebar.insertBefore(calibrationSidebar, $(".v2-sidebar-footer"));
const calibrationFooter = document.createElement("footer");
calibrationFooter.className = "v2-sidebar-footer v2-calibration-footer";
calibrationFooter.innerHTML = `
<span class="v2-live-dot"></span>
<div><strong>自标定安全链路</strong><small id="calibrationConnectionText">尚未连接台架</small></div>
<button type="button" id="calibrationGlobalStop" title="全轴安全停止" aria-label="全轴安全停止"><i data-lucide="octagon"></i></button>`;
sidebar.append(calibrationFooter);
const root = document.createElement("section");
root.className = "v2-self-calibration";
root.id = "v2SelfCalibration";
root.setAttribute("aria-label", "N7 自标定工作台");
root.innerHTML = `
<header class="v2-calibration-heading">
<div>
<span class="section-kicker" id="calibrationPageKicker">N7 SEAT CALIBRATION</span>
<h1 id="calibrationPageTitle">座椅 Hall 标定</h1>
<p id="calibrationPageDescription">四轴位置、端点与自动标定</p>
</div>
<div class="v2-calibration-statuses">
<div><i id="calibrationCanDot"></i><span><small>CAN</small><strong id="calibrationCanState">未连接</strong></span></div>
<div><i id="calibrationMotorDot"></i><span><small>MOTOR</small><strong id="calibrationMotorState">等待状态</strong></span></div>
<div><i id="calibrationMqttDot"></i><span><small>MQTT</small><strong id="calibrationMqttState">等待数据</strong></span></div>
</div>
</header>
<section class="v2-calibration-view active" data-calibration-page="hall">
<div class="v2-calibration-hero-grid">
<section class="v2-calibration-panel v2-calibration-stage">
<header><div><span class="section-kicker">DIGITAL TWIN</span><h2>实时座椅姿态</h2></div><span class="v2-calibration-badge wait" id="calibrationPhaseBadge">待命</span></header>
<div class="v2-calibration-seat-scene">
<div class="v2-calibration-floor-grid"></div>
<div id="seatModelHost" class="seat-model-host">
<canvas id="seatCanvas" aria-label="N7 原始三维座椅模型,可拖动旋转并滚轮缩放"></canvas>
<div id="seatModelLoading" class="model-loading">
<span class="model-spinner"></span>
<strong>正在加载原始 N7 座椅</strong>
<small>ANDROID GLB · LOCAL</small>
</div>
<div class="orbit-hint">拖动旋转 · 滚轮缩放 · 双击复位</div>
</div>
<div class="v2-axis-tag tag-back">靠背 <b id="calibrationTagBack">0.0°</b></div>
<div class="v2-axis-tag tag-base">前后 <b id="calibrationTagLong">0.00 cm</b></div>
<div class="v2-axis-tag tag-leg">腿托 <b id="calibrationTagLeg">0.0°</b></div>
<div class="v2-axis-tag tag-height">高度 <b id="calibrationTagHeight">0.00 cm</b></div>
</div>
<div class="v2-calibration-operation">
<i></i><div><small>当前任务</small><strong id="calibrationOperationLabel">等待设备检测</strong></div><span>JOB <b id="calibrationJobId">—</b></span>
</div>
</section>
<section class="v2-calibration-panel v2-calibration-workflow">
<header><div><span class="section-kicker">CALIBRATION FLOW</span><h2>自动标定</h2></div></header>
<p>依次寻找近端、远端并返回近端,完成后保存真实 Hall 量程。</p>
<div class="v2-calibration-flow">
<div data-phase="1"><span>01</span><div><strong>寻找最近点</strong><small>建立机械零位</small></div></div>
<i></i>
<div data-phase="2"><span>02</span><div><strong>寻找最远点</strong><small>测量完整量程</small></div></div>
<i></i>
<div data-phase="3"><span>03</span><div><strong>返回最近点</strong><small>验证端点一致性</small></div></div>
</div>
<button class="v2-calibration-primary" id="calibrationStartAll" type="button" disabled><span>开始四轴自动标定</span><i data-lucide="arrow-right"></i></button>
<div class="v2-calibration-safety"><i data-lucide="shield-check"></i><p><strong>安全租约已启用</strong><br>运动期间每 500 ms 发送心跳,超时由固件自动 STOP。</p></div>
</section>
</div>
<section class="v2-calibration-axis-section">
<header><div><span class="section-kicker">LIVE AXES</span><h2>四轴实时控制</h2></div><p>松开滑块发送目标位置;端点按钮为一次性命令。</p></header>
<div class="v2-calibration-axis-grid" id="calibrationAxisCards"></div>
</section>
</section>
<section class="v2-calibration-view" data-calibration-page="stereo" hidden>
<div class="v2-calibration-stereo-grid">
<section class="v2-calibration-panel v2-calibration-mqtt-panel">
<header><div><span class="section-kicker">ZED / SEATPOSE</span><h2>双目位姿数据</h2></div><span class="v2-calibration-badge wait" id="calibrationMqttBadge">等待数据</span></header>
<div class="v2-calibration-metrics"><div><small>消息总数</small><strong id="calibrationMqttCount">0</strong></div><div><small>最新帧</small><strong id="calibrationMqttFrame">—</strong></div><div><small>解析错误</small><strong id="calibrationMqttErrors">0</strong></div></div>
<p class="v2-calibration-detail" id="calibrationMqttDetail">MQTT 尚未连接</p>
<div class="v2-calibration-pose-areas" id="calibrationPoseAreas"></div>
<div class="v2-calibration-actions"><button id="calibrationMqttConnect" type="button">连接 MQTT</button><button id="calibrationMqttDisconnect" type="button">断开</button><button id="calibrationMqttTest" type="button">发送回环测试</button></div>
</section>
<section class="v2-calibration-panel v2-calibration-video-panel">
<header><div><span class="section-kicker">RTSP MONITOR</span><h2>ZED 实时画面</h2></div><span class="v2-calibration-badge wait" id="calibrationVideoBadge">未启动</span></header>
<div class="v2-calibration-video-frame"><img id="calibrationVideoImage" alt="RTSP 视频画面"><div id="calibrationVideoPlaceholder"><i data-lucide="scan-line"></i><strong>RTSP → MJPEG</strong><small>由现有 Python 服务通过 FFmpeg 转发</small></div></div>
<p class="v2-calibration-detail" id="calibrationVideoDetail">视频接收尚未启动</p>
<div class="v2-calibration-actions"><button id="calibrationVideoStart" type="button">开始接收</button><button id="calibrationVideoStop" type="button">停止</button></div>
</section>
</div>
<section class="v2-calibration-panel v2-calibration-mapping-panel">
<header><div><span class="section-kicker">HALL ↔ 3D</span><h2>实时映射概览</h2></div></header>
<div class="v2-calibration-mapping-rows" id="calibrationMappingRows"></div>
</section>
</section>
<section class="v2-calibration-view" data-calibration-page="system" hidden>
<div class="v2-calibration-settings-grid">
<form class="v2-calibration-panel v2-calibration-settings-card" id="calibrationCanForm">
<header><span>CAN</span><div><small>TRANSPORT</small><h2>CAN 适配器</h2></div></header>
<p class="v2-calibration-form-note">执行目标由左侧下拉菜单选择。</p>
<div class="v2-calibration-two-col"><label>接口<input id="calibrationCanInterface" value="zlgcan"></label><label>通道<input id="calibrationCanChannel" value="0"></label></div>
<label>位速率<input id="calibrationCanBitrate" value="500000" readonly></label>
<label>ZCANPRO Python<input id="calibrationCanPython" value="C:\\Program Files (x86)\\ZCANPRO\\python38\\python.exe"></label>
<label>ZLG DLL<input id="calibrationCanDll" value="C:\\Program Files (x86)\\ZCANPRO\\zlgcan.dll"></label>
<div class="v2-calibration-two-col"><label>设备类型<input id="calibrationCanDeviceType" type="number" value="41"></label><label>设备序号<input id="calibrationCanDeviceIndex" type="number" value="0"></label></div>
<button class="v2-calibration-primary" type="submit">保存并连接台架</button>
<p class="v2-calibration-detail" id="calibrationTransportDetail"></p>
</form>
<form class="v2-calibration-panel v2-calibration-settings-card" id="calibrationMqttForm">
<header><span>MQ</span><div><small>STEREO DATA</small><h2>MQTT Broker</h2></div></header>
<div class="v2-calibration-two-col"><label>主机<input id="calibrationMqttHost"></label><label>端口<input id="calibrationMqttPort" type="number"></label></div>
<label>主题<input id="calibrationMqttTopic"></label>
<button type="submit">保存 MQTT 设置</button>
<p class="v2-calibration-form-note">正式数据主题默认:zed/seatpose</p>
</form>
<form class="v2-calibration-panel v2-calibration-settings-card" id="calibrationVideoForm">
<header><span>RT</span><div><small>VIDEO</small><h2>RTSP 视频</h2></div></header>
<div class="v2-calibration-two-col"><label>主机<input id="calibrationVideoHost"></label><label>端口<input id="calibrationVideoPort" type="number"></label></div>
<label>流路径<input id="calibrationVideoPath"></label>
<button type="submit">保存 RTSP 设置</button>
<p class="v2-calibration-form-note" id="calibrationFfmpegHint"></p>
</form>
</div>
</section>`;
moduleSwitcher.insertAdjacentElement("afterend", root);
const target = $("#calibrationExecutionSelect");
target.value = sessionStorage.getItem(TARGET_KEY) === "simulation"
? "simulation"
: "hardware";
window.CognitiveAgentConsoleV2?.enhanceSelect(target);
return true;
}
function isAdmin() {
return window.CognitiveAgentConsoleV2?.getRole() === "admin"
|| document.body.dataset.demoRole === "admin";
}
function isActive() {
return isAdmin() && document.body.dataset.module === "self-calibration";
}
function toast(message, error = false) {
const node = $("#toast");
if (!node) return;
node.textContent = message;
node.className = error ? "toast show error" : "toast show";
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => {
node.className = "toast";
}, 3600);
}
async function api(path, body = {}) {
const response = await fetch(`${API_ROOT}${path}`, {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Cognitive-Agent-Role": "admin",
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({
message: `HTTP ${response.status}`,
}));
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
if (data.state) render(data.state);
return data;
}
async function fetchState() {
const response = await fetch(`${API_ROOT}/state`, {
credentials: "same-origin",
headers: {
Accept: "application/json",
"X-Cognitive-Agent-Role": "admin",
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
render(data);
}
async function run(action, success = "") {
try {
await action();
if (success) toast(success);
} catch (error) {
toast(error.message || String(error), true);
}
}
function statusClass(state) {
if (["online", "subscribed", "simulated", "playing"].includes(state)) return "online";
if (["connecting", "idle"].includes(state)) return "wait";
if (state === "error") return "error";
return "";
}
function setStatusDot(selector, state) {
const node = $(selector);
if (node) node.className = statusClass(state);
}
function render(state) {
if (!state?.transport || !state?.controller) return;
currentState = state;
const { transport, controller, mqtt } = state;
setStatusDot("#calibrationCanDot", transport.state);
setStatusDot(
"#calibrationMotorDot",
controller.motor_ready ? "online" : transport.state === "online" ? "connecting" : transport.state,
);
setStatusDot("#calibrationMqttDot", mqtt.state);
$("#calibrationCanState").textContent = transport.state === "online"
? transport.mode === "simulation" ? "仿真在线" : "实机在线"
: transport.state === "connecting" ? "连接中"
: transport.state === "error" ? "连接异常" : "未连接";
$("#calibrationMotorState").textContent = controller.motor_ready
? controller.armed ? "已解锁" : "已就绪"
: "等待状态";
$("#calibrationMqttState").textContent = mqtt.state === "simulated"
? "仿真数据"
: mqtt.state === "subscribed" ? "已订阅"
: mqtt.state === "connecting" ? "连接中" : "未连接";
$("#calibrationPhaseBadge").textContent =
state.operation.phase_name || phaseLabels[state.operation.phase] || "待命";
$("#calibrationPhaseBadge").className = `v2-calibration-badge ${
[5, 6].includes(state.operation.phase)
? "error"
: state.operation.phase === 0 ? "wait" : ""
}`;
$("#calibrationOperationLabel").textContent = state.operation.label;
$("#calibrationJobId").textContent = controller.job_id || "—";
$("#calibrationTransportDetail").textContent = transport.detail;
$("#calibrationConnectionText").textContent = transport.detail;
const targetState = $("#calibrationTargetState");
targetState.textContent = transport.state === "online" ? "READY"
: transport.state === "connecting" ? "LINK" : transport.state === "error" ? "ERROR" : "IDLE";
$("#calibrationTargetCard").dataset.state = transport.state;
$("#calibrationTargetNote").textContent = transport.detail;
renderWorkflow(state.operation.phase);
renderAxes(state.axes, controller.job_active, transport.state === "online");
renderSeat(state.axes);
renderMqtt(mqtt);
renderVideo(state.video);
renderMappings(state.axes, mqtt.areas);
$("#calibrationStartAll").disabled =
transport.state !== "online" || controller.job_active;
if (!settingsHydrated) hydrateSettings(state.settings, state.video.ffmpeg_available);
}
function renderWorkflow(phase) {
$$(".v2-calibration-flow > div").forEach((step) => {
const value = Number(step.dataset.phase);
step.classList.toggle("active", phase === value);
step.classList.toggle("done", phase === 4 || (phase > value && phase < 5));
});
}
function createAxisCards(axes) {
const container = $("#calibrationAxisCards");
if (!container) return;
axes.forEach((axis) => {
const card = document.createElement("article");
card.className = "v2-calibration-axis-card";
card.dataset.axis = axis.id;
card.innerHTML = `
<header><span>AXIS 0${axis.id + 1}</span><strong></strong><b>就绪</b></header>
<div class="v2-calibration-axis-reading"><strong>0</strong><span>HALL 0 / 0</span></div>
<input type="range" min="0" max="1000" value="0" aria-label="${axis.name}位置">
<div class="v2-calibration-range-labels"><span>近端 0%</span><span>远端 100%</span></div>
<div class="v2-calibration-axis-actions"><button data-seek="near" type="button">近端</button><button data-axis-calibrate type="button">标定</button><button data-seek="far" type="button">远端</button></div>`;
$("header strong", card).textContent = axis.title;
const slider = $('input[type="range"]', card);
slider.addEventListener("input", () => {
$(".v2-calibration-axis-reading strong", card).textContent =
formatPhysical(axis, Number(slider.value));
});
slider.addEventListener("change", () => run(
() => api("/move", { axis: axis.id, permille: Number(slider.value) }),
));
$('[data-seek="near"]', card).addEventListener("click", () => run(
() => api("/seek", { axis: axis.id, far: false }),
));
$('[data-seek="far"]', card).addEventListener("click", () => run(
() => api("/seek", { axis: axis.id, far: true }),
));
$("[data-axis-calibrate]", card).addEventListener("click", () => run(
() => api("/start", { axis: axis.id }),
`${axis.name}自动标定已启动`,
));
container.append(card);
});
}
function renderAxes(axes, busy, online) {
if (!$("#calibrationAxisCards")?.children.length) createAxisCards(axes);
axes.forEach((axis) => {
const card = $(`.v2-calibration-axis-card[data-axis="${axis.id}"]`);
if (!card) return;
const slider = $('input[type="range"]', card);
if (document.activeElement !== slider) slider.value = axis.permille;
$(".v2-calibration-axis-reading strong", card).textContent =
formatPhysical(axis, Number(slider.value));
$(".v2-calibration-axis-reading span", card).textContent =
`HALL ${axis.hall} / ${axis.range || "—"}`;
const state = $("header b", card);
state.textContent = axis.fault ? "故障"
: axis.calibrating ? "标定中"
: axis.moving ? axis.direction
: axis.position_valid ? "就绪" : "位置未知";
state.className = axis.moving || axis.calibrating ? "moving" : "";
slider.disabled = busy || !online;
$$(".v2-calibration-axis-actions button", card).forEach((button) => {
button.disabled = busy || !online;
});
});
}
function formatPhysical(axis, permille) {
const value = axis.physical_range * permille / 1000;
return axis.unit === "cm" ? `${value.toFixed(2)} ${axis.unit}` : `${value.toFixed(1)}${axis.unit}`;
}
function renderSeat(axes) {
if (!axes?.length) return;
const longitudinal = axes.find((axis) => axis.id === 0) || axes[0];
const legrest = axes.find((axis) => axis.id === 1) || axes[1];
const backrest = axes.find((axis) => axis.id === 2) || axes[2];
const height = axes.find((axis) => axis.id === 3) || axes[3];
const pose = {
longitudinalM:
(longitudinal.permille / 1000 - 0.5) * longitudinal.physical_range * 0.01,
verticalM: (0.5 - height.permille / 1000) * height.physical_range * 0.01,
backrestDeg: backrest.physical_value,
legrestDeg: legrest.physical_value,
};
window.n7SeatPose = pose;
window.dispatchEvent(new CustomEvent("n7-seat-pose", { detail: pose }));
$("#calibrationTagLong").textContent = `${longitudinal.physical_value.toFixed(2)} cm`;
$("#calibrationTagLeg").textContent = `${legrest.physical_value.toFixed(1)}°`;
$("#calibrationTagBack").textContent = `${backrest.physical_value.toFixed(1)}°`;
$("#calibrationTagHeight").textContent = `${height.physical_value.toFixed(2)} cm`;
}
function renderMqtt(mqtt) {
const friendly = {
simulated: "仿真数据",
subscribed: "已订阅",
connecting: "连接中",
disconnected: "未连接",
error: "异常",
};
$("#calibrationMqttBadge").textContent = friendly[mqtt.state] || mqtt.state;
$("#calibrationMqttBadge").className = `v2-calibration-badge ${
mqtt.state === "error"
? "error"
: ["disconnected", "connecting"].includes(mqtt.state) ? "wait" : ""
}`;
$("#calibrationMqttCount").textContent = mqtt.count;
$("#calibrationMqttFrame").textContent = mqtt.last_frame >= 0 ? mqtt.last_frame : "—";
$("#calibrationMqttErrors").textContent = mqtt.parse_errors;
$("#calibrationMqttDetail").textContent = mqtt.detail;
const container = $("#calibrationPoseAreas");
container.replaceChildren();
const entries = Object.entries(mqtt.areas || {});
if (!entries.length) {
const empty = document.createElement("p");
empty.className = "v2-calibration-empty";
empty.textContent = "等待 Seat_cushion / Seat_backrest / Seat_leg_rest 位姿…";
container.append(empty);
}
entries.forEach(([name, pose]) => {
const row = document.createElement("div");
row.className = "v2-calibration-pose-row";
const title = document.createElement("strong");
title.textContent = name;
const angle = document.createElement("span");
angle.textContent = pose.angle == null ? "ANGLE —" : `${Number(pose.angle).toFixed(2)}°`;
const translation = document.createElement("small");
translation.textContent = pose.translation
? `T [${pose.translation.slice(0, 3).map((value) => Number(value).toFixed(3)).join(", ")}]`
: "T —";
row.append(title, angle, translation);
container.append(row);
});
}
function renderVideo(video) {
const friendly = { idle: "未启动", connecting: "连接中", playing: "播放中", error: "异常" };
$("#calibrationVideoBadge").textContent = friendly[video.state] || video.state;
$("#calibrationVideoBadge").className = `v2-calibration-badge ${
video.state === "error" ? "error" : video.state === "idle" ? "wait" : ""
}`;
$("#calibrationVideoDetail").textContent = video.detail;
const image = $("#calibrationVideoImage");
if (video.state === "playing" && !image.classList.contains("playing")) {
image.src = `${API_ROOT}/video/stream?role=admin&t=${Date.now()}`;
image.classList.add("playing");
}
if (!["playing", "connecting"].includes(video.state)) {
image.classList.remove("playing");
image.removeAttribute("src");
}
}
function renderMappings(axes, areas = {}) {
const container = $("#calibrationMappingRows");
container.replaceChildren();
const areaFor = ["Seat_cushion", "Seat_leg_rest", "Seat_backrest", "Seat_cushion"];
axes.forEach((axis) => {
const pose = areas[areaFor[axis.id]] || {};
const observed = axis.id === 0 || axis.id === 3
? pose.translation?.[axis.id === 0 ? 0 : 1]
: pose.angle;
const row = document.createElement("div");
row.className = "v2-calibration-mapping-row";
row.innerHTML = "<strong></strong><span></span><div><i></i></div><b></b>";
$("strong", row).textContent = axis.name;
$("span", row).textContent = `${axis.hall} Hall`;
$("i", row).style.width = `${axis.permille / 10}%`;
$("b", row).textContent = observed == null
? "3D —"
: `${Number(observed).toFixed(3)}${axis.id === 1 || axis.id === 2 ? "°" : " T"}`;
container.append(row);
});
}
function hydrateSettings(settings, ffmpegAvailable) {
settingsHydrated = true;
$("#calibrationCanInterface").value = settings.can.interface;
$("#calibrationCanChannel").value = settings.can.channel;
$("#calibrationCanBitrate").value = settings.can.bitrate;
$("#calibrationCanPython").value = settings.can.python_executable || "";
$("#calibrationCanDll").value = settings.can.dll_path || "";
$("#calibrationCanDeviceType").value = settings.can.device_type ?? 41;
$("#calibrationCanDeviceIndex").value = settings.can.device_index ?? 0;
$("#calibrationMqttHost").value = settings.mqtt.host;
$("#calibrationMqttPort").value = settings.mqtt.port;
$("#calibrationMqttTopic").value = settings.mqtt.topic;
$("#calibrationVideoHost").value = settings.video.host;
$("#calibrationVideoPort").value = settings.video.port;
$("#calibrationVideoPath").value = settings.video.path;
$("#calibrationFfmpegHint").textContent = ffmpegAvailable
? "已检测到 FFmpeg,可转发 RTSP。"
: "未检测到 FFmpeg;其余功能不受影响。";
}
function collectSettings() {
return {
can: {
mode: $("#calibrationExecutionSelect").value,
interface: $("#calibrationCanInterface").value.trim(),
channel: $("#calibrationCanChannel").value.trim(),
bitrate: Number($("#calibrationCanBitrate").value),
python_executable: $("#calibrationCanPython").value.trim(),
dll_path: $("#calibrationCanDll").value.trim(),
device_type: Number($("#calibrationCanDeviceType").value),
device_index: Number($("#calibrationCanDeviceIndex").value),
resistance: 1,
},
mqtt: {
host: $("#calibrationMqttHost").value.trim(),
port: Number($("#calibrationMqttPort").value),
topic: $("#calibrationMqttTopic").value.trim(),
},
video: {
host: $("#calibrationVideoHost").value.trim(),
port: Number($("#calibrationVideoPort").value),
path: $("#calibrationVideoPath").value.trim(),
},
};
}
function showView(view) {
activeView = pageTitles[view] ? view : "hall";
$$(".v2-calibration-view").forEach((page) => {
const active = page.dataset.calibrationPage === activeView;
page.hidden = !active;
page.classList.toggle("active", active);
});
$$("[data-calibration-view]").forEach((button) => {
button.classList.toggle("active", button.dataset.calibrationView === activeView);
});
$("#calibrationSettingsLink").classList.toggle("active", activeView === "system");
const [kicker, title, description] = pageTitles[activeView];
$("#calibrationPageKicker").textContent = kicker;
$("#calibrationPageTitle").textContent = title;
$("#calibrationPageDescription").textContent = description;
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
}
function updateTargetPresentation() {
const simulation = $("#calibrationExecutionSelect").value === "simulation";
sessionStorage.setItem(TARGET_KEY, simulation ? "simulation" : "hardware");
$("#calibrationConnectTarget span").textContent =
simulation ? "连接标定仿真台架" : "连接真实台架";
if (!currentState || currentState.transport.state === "disconnected") {
$("#calibrationTargetNote").textContent =
simulation ? "完整 CAN 协议仿真,不驱动实体设备" : "本机 admin 可连接真实 CAN 台架";
}
}
function connectTarget() {
const settings = collectSettings();
return run(async () => {
await api("/settings", settings);
await api("/can/connect", { can: settings.can });
}, settings.can.mode === "simulation" ? "标定仿真台架已连接" : "真实台架连接完成");
}
function loadSeatModel() {
if (modelRequested || !$("#seatModelHost")) return;
modelRequested = true;
const script = document.createElement("script");
script.src = "/assets/self-calibration-model.bundle.js";
script.async = true;
script.onerror = () => {
modelRequested = false;
$("#seatModelLoading")?.classList.add("error");
};
document.head.append(script);
}
function closeEvents() {
window.clearTimeout(reconnectTimer);
reconnectTimer = 0;
eventSource?.close();
eventSource = null;
}
function connectEvents() {
if (!isActive() || eventSource) return;
eventSource = new EventSource(`${API_ROOT}/events?role=admin`);
eventSource.onmessage = (event) => {
try {
render(JSON.parse(event.data));
} catch (error) {
console.error("自标定状态解析失败", error);
}
};
eventSource.onerror = () => {
closeEvents();
if (isActive()) reconnectTimer = window.setTimeout(connectEvents, 1600);
};
}
function activate() {
if (!isActive()) {
closeEvents();
return;
}
loadSeatModel();
fetchState().catch((error) => toast(`自标定服务连接失败:${error.message}`, true));
connectEvents();
}
function bindEvents() {
$$("[data-calibration-view]").forEach((button) => {
button.addEventListener("click", () => showView(button.dataset.calibrationView));
});
$("#calibrationSettingsLink").addEventListener("click", () => showView("system"));
$("#calibrationExecutionSelect").addEventListener("change", updateTargetPresentation);
$("#calibrationConnectTarget").addEventListener("click", connectTarget);
$("#calibrationGlobalStop").addEventListener("click", () => run(
() => api("/stop"),
"全轴 STOP 已确认",
));
$("#calibrationStartAll").addEventListener("click", () => run(
() => api("/start", { axis: "all" }),
"四轴自动标定已启动",
));
$("#calibrationMqttConnect").addEventListener("click", () => run(
() => api("/mqtt/connect"),
"正在连接 MQTT",
));
$("#calibrationMqttDisconnect").addEventListener("click", () => run(
() => api("/mqtt/disconnect"),
"MQTT 已断开",
));
$("#calibrationMqttTest").addEventListener("click", () => run(
() => api("/mqtt/test"),
"MQTT 测试帧已发送",
));
$("#calibrationVideoStart").addEventListener("click", () => run(
() => api("/video/start"),
"正在启动 RTSP 接收",
));
$("#calibrationVideoStop").addEventListener("click", () => run(
() => api("/video/stop"),
"视频接收已停止",
));
$("#calibrationCanForm").addEventListener("submit", (event) => {
event.preventDefault();
connectTarget();
});
$("#calibrationMqttForm").addEventListener("submit", (event) => {
event.preventDefault();
run(() => api("/settings", collectSettings()), "MQTT 设置已保存");
});
$("#calibrationVideoForm").addEventListener("submit", (event) => {
event.preventDefault();
run(() => api("/settings", collectSettings()), "RTSP 设置已保存");
});
window.addEventListener("cognitive-agent:module-change", activate);
window.addEventListener("cognitive-agent:demo-role-change", activate);
window.addEventListener("beforeunload", closeEvents);
}
function init() {
if (!buildShell()) return;
bindEvents();
updateTargetPresentation();
showView(activeView);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
activate();
}
document.addEventListener("DOMContentLoaded", init);
}());
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+483
View File
@@ -0,0 +1,483 @@
(function () {
"use strict";
function init() {
const THREE = window.THREE;
const canvas = document.getElementById("repairSeatCanvas");
const viewer = document.getElementById("repairSeatViewer");
if (!canvas || !viewer || viewer.dataset.seat3dInitialized === "true") return;
viewer.dataset.seat3dInitialized = "true";
const buildFallback = () => {
const fallback = document.createElement("div");
fallback.className = "v2-repair-seat-fallback";
fallback.setAttribute("aria-hidden", "true");
fallback.innerHTML = `
<div class="repair-css-seat">
<div class="repair-css-headrest"></div>
<div class="repair-css-back"><i></i></div>
<div class="repair-css-cushion"><i></i></div>
<div class="repair-css-legrest"></div>
<div class="repair-css-base"></div>
<div class="repair-css-rails"><i></i><i></i></div>
</div>
<span class="repair-css-halo"></span>`;
viewer.insertBefore(fallback, canvas.nextSibling);
window.addEventListener("seat-healing-state", (event) => {
const detail = event.detail || {};
const axes = new Map((detail.axes || []).map((axis) => [Number(axis.motor), Number(axis.position_pct || 0)]));
fallback.style.setProperty("--repair-slide", `${(50 - (axes.get(0) ?? 35)) * 0.18}px`);
fallback.style.setProperty("--repair-lift", `${((axes.get(1) ?? 42) - 50) * 0.1}px`);
fallback.style.setProperty("--repair-back", `${-8 - (axes.get(2) ?? 48) * 0.24}deg`);
fallback.style.setProperty("--repair-leg", `${-5 + (axes.get(3) ?? 20) * 0.58}deg`);
fallback.classList.toggle("heating", Number(detail.heatingLevel || 0) > 0);
fallback.classList.toggle("ventilating", Boolean(detail.ventilation));
fallback.classList.toggle("active", !["IDLE", "PASSED"].includes(detail.state));
fallback.classList.toggle("failed", detail.state === "FAILED");
});
viewer.classList.add("ready", "fallback");
};
if (!THREE) {
buildFallback();
return;
}
const themeProbe = document.createElement("div");
themeProbe.className = "console-v2";
themeProbe.dataset.demoRole = "admin";
themeProbe.style.display = "none";
document.body.append(themeProbe);
const consoleStyles = window.getComputedStyle(themeProbe);
const cssThemeColor = (name, fallback) => {
const value = consoleStyles?.getPropertyValue(name).trim() || fallback;
try {
return new THREE.Color(value);
} catch (_error) {
return new THREE.Color(fallback);
}
};
const white = new THREE.Color(0xffffff);
const black = new THREE.Color(0x000000);
const themeAccent = cssThemeColor("--v2-accent", "#315eaa");
const themeStrong = cssThemeColor("--v2-accent-strong", "#234785");
const themeSoft = cssThemeColor("--v2-accent-soft", "#e1e9fa");
themeProbe.remove();
const mixHex = (from, to, amount) => from.clone().lerp(to, amount).getHex();
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(mixHex(themeSoft, white, 0.12), 8, 15);
let renderer;
try {
renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
} catch (_error) {
buildFallback();
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.92;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const camera = new THREE.PerspectiveCamera(33, 1, 0.05, 40);
const target = new THREE.Vector3(0, 1.15, 0);
const orbit = { yaw: 0.67, pitch: 0.21, distance: 6.8 };
const pointer = { active: false, x: 0, y: 0 };
scene.add(new THREE.HemisphereLight(0xffffff, mixHex(themeStrong, white, 0.46), 1.35));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.8);
keyLight.position.set(-4, 7, 6);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(mixHex(themeAccent, white, 0.42), 0.72);
fillLight.position.set(5, 3, -4);
scene.add(fillLight);
const floor = new THREE.Mesh(
new THREE.CircleGeometry(4.8, 80),
new THREE.MeshStandardMaterial({ color: mixHex(themeSoft, white, 0.18), roughness: 0.94 }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.16;
floor.receiveShadow = true;
scene.add(floor);
const grid = new THREE.GridHelper(8, 20, themeAccent.getHex(), mixHex(themeAccent, white, 0.7));
grid.position.y = -0.153;
grid.material.transparent = true;
grid.material.opacity = 0.14;
scene.add(grid);
const seatShell = new THREE.MeshPhysicalMaterial({
color: mixHex(themeStrong, black, 0.06),
roughness: 0.54,
metalness: 0.02,
clearcoat: 0.16,
clearcoatRoughness: 0.7,
emissive: mixHex(themeStrong, black, 0.64),
emissiveIntensity: 0.08,
});
const seatInsert = new THREE.MeshPhysicalMaterial({
color: mixHex(themeAccent, white, 0.48),
roughness: 0.72,
metalness: 0,
clearcoat: 0.06,
emissive: 0x000000,
emissiveIntensity: 0,
});
const thermalInsert = seatInsert.clone();
const bolsterMaterial = new THREE.MeshStandardMaterial({
color: themeAccent.getHex(),
roughness: 0.76,
emissive: 0x000000,
emissiveIntensity: 0,
});
const structureMaterial = new THREE.MeshStandardMaterial({
color: mixHex(themeStrong, black, 0.34),
roughness: 0.62,
metalness: 0.35,
});
const railMaterial = new THREE.MeshStandardMaterial({ color: 0x7b8798, roughness: 0.42, metalness: 0.72 });
const edgeMaterial = new THREE.LineBasicMaterial({
color: mixHex(themeStrong, black, 0.38),
transparent: true,
opacity: 0.3,
});
const seamMaterial = new THREE.LineBasicMaterial({
color: mixHex(themeAccent, white, 0.42),
transparent: true,
opacity: 0.72,
});
function addEdges(mesh, geometry, opacity = 0.3) {
const material = edgeMaterial.clone();
material.opacity = opacity;
mesh.add(new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 38), material));
}
function makeBox(parent, size, position, material) {
const geometry = new THREE.BoxGeometry(...size);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...position);
mesh.castShadow = true;
mesh.receiveShadow = true;
addEdges(mesh, geometry, 0.26);
parent.add(mesh);
return mesh;
}
function makePanel(parent, shape, depth, position, rotation, material) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
steps: 1,
curveSegments: 20,
bevelEnabled: true,
bevelSegments: 4,
bevelSize: 0.045,
bevelThickness: 0.04,
});
geometry.translate(0, 0, -depth / 2);
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...position);
mesh.rotation.set(...rotation);
mesh.castShadow = true;
mesh.receiveShadow = true;
addEdges(mesh, geometry);
parent.add(mesh);
return mesh;
}
function cushionShape(inset = 0) {
const shape = new THREE.Shape();
const half = 0.69 - inset;
shape.moveTo(-0.52 + inset, 0.61 - inset);
shape.quadraticCurveTo(-half, 0.55 - inset, -half, 0.34);
shape.lineTo(-half + 0.025, -0.39 + inset);
shape.quadraticCurveTo(-half + 0.02, -0.61 + inset, -0.48 + inset, -0.68 + inset);
shape.quadraticCurveTo(0, -0.77 + inset, 0.48 - inset, -0.68 + inset);
shape.quadraticCurveTo(half - 0.02, -0.61 + inset, half - 0.025, -0.39 + inset);
shape.lineTo(half, 0.34);
shape.quadraticCurveTo(half, 0.55 - inset, 0.52 - inset, 0.61 - inset);
shape.quadraticCurveTo(0, 0.68 - inset, -0.52 + inset, 0.61 - inset);
return shape;
}
function backrestShape(inset = 0) {
const shape = new THREE.Shape();
const half = 0.7 - inset;
shape.moveTo(-0.52 + inset, 0);
shape.quadraticCurveTo(-half, 0.08, -half, 0.34);
shape.lineTo(-half + 0.035, 1.28 - inset);
shape.quadraticCurveTo(-half + 0.06, 1.52 - inset, -0.46 + inset, 1.65 - inset);
shape.quadraticCurveTo(0, 1.82 - inset, 0.46 - inset, 1.65 - inset);
shape.quadraticCurveTo(half - 0.06, 1.52 - inset, half - 0.035, 1.28 - inset);
shape.lineTo(half, 0.34);
shape.quadraticCurveTo(half, 0.08, 0.52 - inset, 0);
shape.quadraticCurveTo(0, -0.08 + inset, -0.52 + inset, 0);
return shape;
}
function makeSeam(parent, points) {
const geometry = new THREE.BufferGeometry().setFromPoints(points.map((point) => new THREE.Vector3(...point)));
parent.add(new THREE.Line(geometry, seamMaterial));
}
const seatRoot = new THREE.Group();
seatRoot.rotation.y = -0.38;
seatRoot.position.set(0, 0.02, 0);
scene.add(seatRoot);
makeBox(seatRoot, [0.11, 0.08, 2.65], [-0.48, 0.12, 0], railMaterial);
makeBox(seatRoot, [0.11, 0.08, 2.65], [0.48, 0.12, 0], railMaterial);
makeBox(seatRoot, [1.15, 0.07, 0.13], [0, 0.15, -0.92], structureMaterial);
makeBox(seatRoot, [1.15, 0.07, 0.13], [0, 0.15, 0.92], structureMaterial);
const slideRoot = new THREE.Group();
seatRoot.add(slideRoot);
const base = new THREE.Mesh(new THREE.CapsuleGeometry(0.47, 0.5, 6, 18), structureMaterial);
base.rotation.x = Math.PI / 2;
base.scale.set(1.42, 1, 0.55);
base.position.set(0, 0.34, -0.02);
base.castShadow = true;
slideRoot.add(base);
const cushionPivot = new THREE.Group();
cushionPivot.position.set(0, 0.57, 0.06);
slideRoot.add(cushionPivot);
makePanel(cushionPivot, cushionShape(), 0.22, [0, 0, 0.08], [-Math.PI / 2, 0, 0], seatShell);
makePanel(cushionPivot, cushionShape(0.16), 0.055, [0, 0.145, 0.03], [-Math.PI / 2, 0, 0], thermalInsert);
[-1, 1].forEach((side) => {
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.105, 0.86, 5, 12), bolsterMaterial);
wing.rotation.x = Math.PI / 2;
wing.position.set(side * 0.61, 0.17, 0.08);
wing.castShadow = true;
cushionPivot.add(wing);
});
makeSeam(cushionPivot, [[-0.28, 0.181, 0.5], [-0.22, 0.181, 0.08], [-0.28, 0.181, -0.42]]);
makeSeam(cushionPivot, [[0.28, 0.181, 0.5], [0.22, 0.181, 0.08], [0.28, 0.181, -0.42]]);
const backrestPivot = new THREE.Group();
backrestPivot.position.set(0, 0.62, -0.5);
backrestPivot.rotation.x = -0.09;
slideRoot.add(backrestPivot);
makePanel(backrestPivot, backrestShape(), 0.3, [0, 0, 0], [0, 0, 0], seatShell);
makePanel(backrestPivot, backrestShape(0.16), 0.07, [0, 0.04, 0.19], [0, 0, 0], thermalInsert);
[-1, 1].forEach((side) => {
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.115, 1.2, 6, 14), bolsterMaterial);
wing.position.set(side * 0.61, 0.78, 0.18);
wing.scale.set(1, 1, 0.72);
wing.castShadow = true;
backrestPivot.add(wing);
});
makeSeam(backrestPivot, [[-0.28, 0.2, 0.232], [-0.22, 0.83, 0.232], [-0.32, 1.46, 0.232]]);
makeSeam(backrestPivot, [[0.28, 0.2, 0.232], [0.22, 0.83, 0.232], [0.32, 1.46, 0.232]]);
makeSeam(backrestPivot, [[-0.32, 0.86, 0.234], [0, 0.78, 0.234], [0.32, 0.86, 0.234]]);
makeBox(backrestPivot, [0.045, 0.42, 0.045], [-0.24, 1.74, 0], railMaterial);
makeBox(backrestPivot, [0.045, 0.42, 0.045], [0.24, 1.74, 0], railMaterial);
const headrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 0.3, 8, 18), seatShell);
headrest.scale.set(1.35, 1, 0.5);
headrest.position.set(0, 1.98, 0.01);
headrest.castShadow = true;
backrestPivot.add(headrest);
const headrestInsert = new THREE.Mesh(new THREE.CapsuleGeometry(0.245, 0.23, 8, 18), seatInsert);
headrestInsert.scale.set(1.32, 1, 0.28);
headrestInsert.position.set(0, 1.98, 0.18);
backrestPivot.add(headrestInsert);
const legrestPivot = new THREE.Group();
legrestPivot.position.set(0, 0.64, 0.7);
legrestPivot.rotation.x = 0.1;
slideRoot.add(legrestPivot);
const legShell = makePanel(legrestPivot, cushionShape(0.08), 0.18, [0, 0, 0.36], [-Math.PI / 2, 0, 0], seatShell);
legShell.scale.set(0.9, 0.72, 0.54);
const legInsert = makePanel(legrestPivot, cushionShape(0.2), 0.045, [0, 0.12, 0.4], [-Math.PI / 2, 0, 0], seatInsert);
legInsert.scale.set(0.92, 0.74, 0.56);
const statusHalo = new THREE.Mesh(
new THREE.RingGeometry(1.12, 1.18, 72),
new THREE.MeshBasicMaterial({
color: themeAccent.getHex(),
transparent: true,
opacity: 0.12,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
statusHalo.position.set(0, 1.35, -0.35);
statusHalo.renderOrder = 2;
seatRoot.add(statusHalo);
const palettes = {
normal: {
shell: mixHex(themeStrong, black, 0.06),
insert: mixHex(themeAccent, white, 0.48),
bolster: themeAccent.getHex(),
emissive: mixHex(themeStrong, black, 0.7),
},
working: {
shell: themeAccent.getHex(),
insert: mixHex(themeAccent, white, 0.58),
bolster: mixHex(themeAccent, themeStrong, 0.38),
emissive: mixHex(themeAccent, black, 0.54),
},
passed: {
shell: themeStrong.getHex(),
insert: mixHex(themeAccent, white, 0.52),
bolster: themeAccent.getHex(),
emissive: mixHex(themeStrong, black, 0.62),
},
failed: { shell: 0x7a3f43, insert: 0xc97a78, bolster: 0x85494c, emissive: 0x4a100d },
};
const current = { axes: [35, 42, 48, 20], ventilation: false, heating: 0, active: false, state: "IDLE" };
const desired = { axes: [35, 42, 48, 20], ventilation: false, heating: 0, active: false, state: "IDLE" };
function updateCamera() {
const cosPitch = Math.cos(orbit.pitch);
camera.position.set(
target.x + Math.sin(orbit.yaw) * cosPitch * orbit.distance,
target.y + Math.sin(orbit.pitch) * orbit.distance,
target.z + Math.cos(orbit.yaw) * cosPitch * orbit.distance,
);
camera.lookAt(target);
viewer.dataset.orbitYaw = orbit.yaw.toFixed(3);
viewer.dataset.orbitPitch = orbit.pitch.toFixed(3);
}
function resetOrbit() {
const aspect = Math.max(0.55, viewer.clientWidth / Math.max(1, viewer.clientHeight));
orbit.yaw = 0.67;
orbit.pitch = 0.21;
orbit.distance = 6.8 * Math.max(1, Math.min(1.3, 0.78 / aspect));
updateCamera();
}
function resize() {
const width = Math.max(1, viewer.clientWidth);
const height = Math.max(1, viewer.clientHeight);
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(pixelRatio);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function onHealingState(event) {
const detail = event.detail || {};
(detail.axes || []).forEach((axis) => {
const motor = Number(axis.motor);
if (motor >= 0 && motor < desired.axes.length) {
desired.axes[motor] = Math.max(0, Math.min(100, Number(axis.position_pct || 0)));
}
});
desired.ventilation = Boolean(detail.ventilation);
desired.heating = Math.max(0, Math.min(3, Number(detail.heatingLevel || 0)));
desired.active = !["IDLE", "PASSED"].includes(detail.state);
desired.state = String(detail.state || "IDLE");
statusHalo.material.color.setHex(detail.state === "FAILED" ? 0xc94d51 : themeAccent.getHex());
}
function animate() {
window.requestAnimationFrame(animate);
for (let index = 0; index < current.axes.length; index += 1) {
current.axes[index] += (desired.axes[index] - current.axes[index]) * 0.055;
}
current.heating += (desired.heating - current.heating) * 0.05;
current.ventilation = desired.ventilation;
current.active = desired.active;
current.state = desired.state;
slideRoot.position.z = THREE.MathUtils.mapLinear(current.axes[0], 0, 100, -0.46, 0.46);
cushionPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[1], 0, 100, -0.08, 0.16);
backrestPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[2], 0, 100, 0.16, -0.44);
legrestPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[3], 0, 100, 0.08, -0.68);
const paletteName = current.state === "FAILED"
? "failed"
: current.state === "PASSED"
? "passed"
: current.active
? "working"
: "normal";
const palette = palettes[paletteName];
seatShell.color.lerp(new THREE.Color(palette.shell), 0.09);
seatInsert.color.lerp(new THREE.Color(palette.insert), 0.09);
thermalInsert.color.lerp(new THREE.Color(palette.insert), 0.09);
bolsterMaterial.color.lerp(new THREE.Color(palette.bolster), 0.09);
const heatRatio = current.heating / 3;
seatShell.emissive.lerp(new THREE.Color(heatRatio > 0 ? 0x8e2f18 : palette.emissive), 0.08);
seatShell.emissiveIntensity = 0.08 + heatRatio * 0.42;
thermalInsert.emissive.lerp(new THREE.Color(current.ventilation ? themeAccent.getHex() : palette.emissive), 0.08);
thermalInsert.emissiveIntensity = current.ventilation ? 0.32 : 0.06;
bolsterMaterial.emissive.lerp(new THREE.Color(palette.emissive), 0.08);
bolsterMaterial.emissiveIntensity = current.active ? 0.12 : 0.04;
statusHalo.rotation.z += current.active ? 0.006 : 0.0015;
statusHalo.material.opacity = current.state === "FAILED"
? 0.2 + Math.sin(performance.now() * 0.005) * 0.06
: current.active
? 0.12 + Math.sin(performance.now() * 0.005) * 0.04
: 0.06;
renderer.render(scene, camera);
}
viewer.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
event.preventDefault();
pointer.active = true;
pointer.x = event.clientX;
pointer.y = event.clientY;
viewer.classList.add("dragging");
});
window.addEventListener("pointermove", (event) => {
if (!pointer.active) return;
event.preventDefault();
orbit.yaw -= (event.clientX - pointer.x) * 0.008;
orbit.pitch = Math.max(-0.08, Math.min(0.72, orbit.pitch + (event.clientY - pointer.y) * 0.006));
pointer.x = event.clientX;
pointer.y = event.clientY;
updateCamera();
}, { passive: false });
const endOrbit = () => {
pointer.active = false;
viewer.classList.remove("dragging");
};
window.addEventListener("pointerup", endOrbit);
window.addEventListener("pointercancel", endOrbit);
window.addEventListener("blur", endOrbit);
viewer.addEventListener("wheel", (event) => {
event.preventDefault();
orbit.distance = Math.max(5.1, Math.min(10, orbit.distance + event.deltaY * 0.003));
updateCamera();
}, { passive: false });
viewer.addEventListener("dblclick", resetOrbit);
window.addEventListener("seat-healing-state", onHealingState);
window.addEventListener("self-repair-seat-reset", resetOrbit);
if (window.ResizeObserver) new ResizeObserver(resize).observe(viewer);
window.addEventListener("resize", resize);
resize();
resetOrbit();
animate();
window.requestAnimationFrame(() => viewer.classList.add("ready"));
}
if (document.readyState === "complete") {
init();
} else {
document.addEventListener("DOMContentLoaded", init, { once: true });
}
}());
File diff suppressed because it is too large Load Diff
+562
View File
@@ -0,0 +1,562 @@
(function () {
"use strict";
const API_ROOT = "/api/self-repair";
const VIEW_KEY = "cognitive-agent.self-repair-view";
const MODE_KEY = "cognitive-agent.self-repair-execution";
const PHASE_INDEX = {
IDLE: -1,
BASELINE: 0,
INJECTED: 1,
DIAGNOSED: 2,
REPAIRING: 3,
VERIFYING: 4,
PASSED: 5,
FAILED: 2,
};
const STATE_TEXT = {
OK: "正常",
WARN: "告警",
FAULT: "故障",
UNKNOWN: "未检测",
UNMONITORED: "关联待测",
NOT_USED: "未启用",
IDLE: "检测中",
};
const TIMELINE = [
{ title: "开始正常任务", phases: ["BASELINE"] },
{ title: "故障发生并停止", phases: ["INJECTED"] },
{ title: "AI判断原因", phases: ["DIAGNOSED"] },
{ title: "执行恢复策略", phases: ["REPAIRING"] },
{ title: "继续原任务", phases: ["VERIFYING", "PASSED"] },
];
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const esc = (value) => String(value == null ? "" : value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
let latest = null;
let selectedCaseId = null;
let selectedPinId = "JC1-1";
let activeView = sessionStorage.getItem(VIEW_KEY) === "hardware"
? "hardware"
: "software";
let executionMode = sessionStorage.getItem(MODE_KEY) === "bench"
? "bench"
: "shadow";
let polling = false;
let toastTimer = 0;
function isAdmin() {
return window.CognitiveAgentConsoleV2?.getRole() === "admin"
|| document.body.dataset.demoRole === "admin";
}
function isActive() {
return isAdmin() && document.body.dataset.module === "self-repair";
}
function toast(message, error = false) {
const node = $("#toast");
if (!node) return;
node.textContent = message;
node.className = error ? "toast show error" : "toast show";
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => { node.className = "toast"; }, 3600);
}
function buildShell() {
const sidebar = $(".v2-sidebar");
const mainPanel = $(".main-panel");
const moduleSwitcher = $("#v2ModuleSwitcher");
if (!sidebar || !mainPanel || !moduleSwitcher || $("#v2SelfRepair")) return false;
const repairSidebar = document.createElement("section");
repairSidebar.className = "v2-sidebar-section v2-repair-sidebar";
repairSidebar.innerHTML = `
<div class="v2-section-heading"><span>SELF-REPAIR DESK</span><h2>控制台</h2></div>
<span class="v2-field-caption">修复对象</span>
<div class="v2-repair-view-switch" role="group" aria-label="修复对象">
<button type="button" data-repair-view="software"><i data-lucide="shield-check"></i><span><strong>软件修复</strong><small>注入 · 判断 · 修复 · 验证</small></span></button>
<button type="button" data-repair-view="hardware"><i data-lucide="cable"></i><span><strong>硬件诊断</strong><small>CAN · 支路 · 连接器针脚</small></span></button>
</div>
<div class="v2-divider"></div>
<span class="v2-field-caption">配置信息</span>
<div class="v2-repair-config-stack">
<div class="v2-repair-config-card"><i data-lucide="car-front"></i><div><small>当前车型</small><strong>腾势 N7</strong></div><span>N7</span></div>
<div class="v2-repair-config-card"><i data-lucide="sparkles"></i><div><small>推理模型</small><strong>GPT-5.6-sol</strong></div><span>READY</span></div>
<div class="v2-repair-config-card"><i data-lucide="cpu"></i><div><small>座椅控制器</small><strong>T2G 4BF</strong></div><span>4BF</span></div>
<div class="v2-repair-config-card v2-config-select-card repair-execution-card">
<i data-lucide="workflow"></i>
<div class="v2-config-card-copy"><small>验证方式</small><div class="v2-config-card-select">
<select id="repairExecutionMode" aria-label="选择自修复验证方式">
<option value="shadow">影子仿真</option>
<option value="bench">实物台架</option>
</select>
</div></div><span id="repairExecutionState">SAFE</span>
</div>
</div>
<p class="v2-repair-config-note" id="repairConfigNote">影子仿真不会写入 ECU 或驱动实体设备</p>`;
sidebar.insertBefore(repairSidebar, $(".v2-sidebar-footer"));
const repairFooter = document.createElement("footer");
repairFooter.className = "v2-sidebar-footer v2-repair-footer offline";
repairFooter.innerHTML = `
<span class="v2-live-dot"></span>
<div><strong>自修复安全链路</strong><small id="repairConnectionText">CAN 桥接离线</small></div>
<button type="button" id="repairEmergencyStop" title="STOP ALL + 热管理关闭" aria-label="自修复紧急停止"><i data-lucide="octagon"></i></button>`;
sidebar.append(repairFooter);
const root = document.createElement("section");
root.className = "v2-self-repair";
root.id = "v2SelfRepair";
root.setAttribute("aria-label", "座椅 AI 自修复验证台");
root.innerHTML = `
<header class="v2-repair-heading">
<div><span class="section-kicker">COGNITIVE SEAT / SELF-REPAIR</span><h1>座椅AI自修复验证台</h1><p>量产软件故障闭环恢复 · 开发板接口在线诊断</p></div>
<div class="v2-repair-status-grid">
<div><i id="repairCanDot"></i><span><small>CAN桥接</small><strong id="repairCanStatus">离线</strong></span></div>
<div><i id="repairProtocolDot"></i><span><small>座椅ECU</small><strong id="repairProtocolStatus">未检测</strong></span></div>
<div><i id="repairAiDot"></i><span><small>AI状态</small><strong id="repairAiStatus">等待验证</strong></span></div>
</div>
</header>
<section class="v2-repair-view" data-repair-page="software">
<header class="v2-repair-toolbar">
<div><span>01</span><div><h2>软件自修复</h2><p>正常任务 → 故障停止 → AI 判断 → 安全修复 → 继续原目标</p></div></div>
<div>
<button type="button" id="repairSoftwareRecords"><i data-lucide="history"></i>验证记录</button>
<button type="button" id="repairFirmwareFlash"><i data-lucide="microchip"></i>构建/烧录</button>
<button type="button" id="repairReset"><i data-lucide="rotate-ccw"></i>重置归零</button>
<button type="button" id="repairRunAll"><i data-lucide="list-checks"></i>批量验证6项</button>
<button class="primary" type="button" id="repairStart"><i data-lucide="play"></i>启动当前用例</button>
</div>
</header>
<div class="v2-repair-software-grid">
<section class="v2-repair-panel v2-repair-cases-panel">
<header><div><span class="section-kicker">PRODUCTION CASES</span><h2>量产故障用例</h2></div><b id="repairBatchCount">0 / 6</b></header>
<div class="v2-repair-overall-progress" aria-label="六项用例总体进度"><span id="repairBatchProgress"></span></div>
<p id="repairBatchSummary">等待启动批量验证</p>
<div class="v2-repair-case-list" id="repairCaseList"></div>
</section>
<section class="v2-repair-panel v2-repair-twin-panel">
<header><div><span class="section-kicker">DIGITAL TWIN</span><h2>座椅3D孪生</h2></div><b id="repairTwinState">正常基线</b></header>
<div class="v2-repair-seat-viewer" id="repairSeatViewer">
<canvas id="repairSeatCanvas" aria-label="自修复座椅三维孪生,可拖动旋转并滚轮缩放"></canvas>
<div class="v2-repair-model-loading" id="repairModelLoading"><span></span><strong>正在构建座椅孪生</strong><small>坐垫 · 靠背 · 腿托 · 滑轨</small></div>
<div class="v2-repair-orbit-hint">拖动旋转 · 滚轮缩放 · 双击复位</div>
<div class="v2-repair-twin-live"><div><small id="repairTwinSource">影子动作仿真</small><strong id="repairTwinTask">等待启动座椅任务</strong><b id="repairTwinStage">待机</b></div><div id="repairAxisStrip"></div><p id="repairTwinReaction">实际动作将同步显示在座椅模型</p></div>
</div>
</section>
<section class="v2-repair-panel v2-repair-timeline-panel">
<header><div><span class="section-kicker">HEALING FLOW</span><h2>座椅任务自修复</h2></div><b id="repairElapsed">0.000 s</b></header>
<div class="v2-repair-timeline" id="repairTimeline"></div>
</section>
</div>
</section>
<section class="v2-repair-view" data-repair-page="hardware" hidden>
<header class="v2-repair-toolbar">
<div><span>02</span><div><h2>硬件自诊断</h2><p>固件资格检查 → CAN 分层诊断 → 21 个启用针脚主动检测</p></div></div>
<div>
<button type="button" id="repairHardwareSelfTest"><i data-lucide="list-checks"></i>规则自检</button>
<button type="button" id="repairHardwareRecords"><i data-lucide="history"></i>诊断记录</button>
<button class="primary" type="button" id="repairHardwareToggle"><i data-lucide="activity"></i>启动硬件诊断</button>
</div>
</header>
<section class="v2-repair-panel v2-repair-link-panel">
<header><div><span class="section-kicker">CAN LAYERS</span><h2>CAN分层诊断</h2></div><p id="repairBridgeMessage">等待桥接状态</p></header>
<div class="v2-repair-link-layers" id="repairLinkLayers"></div>
<p id="repairLinkDetail">尚未收到实时 CAN 帧,针脚保持未检测状态。</p>
</section>
<div class="v2-repair-hardware-grid">
<section class="v2-repair-panel v2-repair-groups-panel"><header><div><span class="section-kicker">FUNCTION BRANCHES</span><h2>功能支路诊断</h2></div><b id="repairHardwareOverall">等待诊断</b></header><div id="repairHardwareGroups"></div></section>
<section class="v2-repair-panel v2-repair-connectors-panel"><header><div><span class="section-kicker">CONNECTOR PINOUT</span><h2>JA1 / JB1 / JC1 全针脚视图</h2></div><div class="v2-repair-pin-legend"><span data-state="OK"></span>正常<span data-state="UNKNOWN"></span>未检测<span data-state="IDLE"></span>检测中<span data-state="WARN"></span>告警<span data-state="FAULT"></span>故障</div></header><div id="repairConnectors"></div></section>
<section class="v2-repair-panel v2-repair-pin-panel"><header><div><span class="section-kicker">PIN EVIDENCE</span><h2 id="repairPinTitle">JC1-1</h2></div><b id="repairPinState">未检测</b></header><div id="repairPinDetail"></div></section>
</div>
</section>
<dialog class="v2-repair-dialog" id="repairSoftwareDialog"><header><div><strong>软件自修复验证记录</strong><small>成功用例自动保存诊断快照</small></div><button type="button" data-repair-close="repairSoftwareDialog">×</button></header><div id="repairSoftwareRecordsBody"></div></dialog>
<dialog class="v2-repair-dialog" id="repairHardwareDialog"><header><div><strong>硬件自诊断记录</strong><small>支路状态变化与最终报告</small></div><button type="button" data-repair-close="repairHardwareDialog">×</button></header><div id="repairHardwareRecordsBody"></div></dialog>`;
moduleSwitcher.insertAdjacentElement("afterend", root);
const select = $("#repairExecutionMode");
select.value = executionMode;
window.CognitiveAgentConsoleV2?.enhanceSelect(select);
return true;
}
async function apiPost(path, body = {}) {
const response = await fetch(`${API_ROOT}${path}`, {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Cognitive-Agent-Role": "admin",
},
body: JSON.stringify(body),
});
const value = await response.json().catch(() => ({ message: `HTTP ${response.status}` }));
if (!response.ok || value.ok === false) {
throw new Error(value.message || value.error || `HTTP ${response.status}`);
}
return value;
}
function showView(view) {
activeView = view === "hardware" ? "hardware" : "software";
sessionStorage.setItem(VIEW_KEY, activeView);
$$('[data-repair-view]').forEach((button) => {
const active = button.dataset.repairView === activeView;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
$$(".v2-repair-view").forEach((page) => {
const active = page.dataset.repairPage === activeView;
page.hidden = !active;
page.classList.toggle("active", active);
});
window.setTimeout(() => {
window.dispatchEvent(new Event("resize"));
if (activeView === "software") {
window.dispatchEvent(new Event("self-repair-seat-reset"));
}
}, 80);
}
function scenarioForDisplay() {
if (!latest?.scenarios?.length) return null;
return latest.scenarios.find((item) => item.id === selectedCaseId)
|| latest.scenarios[0];
}
function timelineDetail(index, scenario, healing) {
const telemetry = healing.telemetry || {};
const details = [
scenario.normal_task,
telemetry.bench_reaction || scenario.fault_reaction,
scenario.diagnosis,
scenario.repair,
scenario.recovery_reaction,
];
const phases = TIMELINE[index].phases;
const event = [...(healing.events || [])].reverse()
.find((item) => phases.includes(item.phase));
return event?.detail || event?.title || details[index] || "等待运行日志";
}
function renderTimeline() {
const healing = latest.healing;
const scenario = scenarioForDisplay();
if (!scenario) return;
const phaseIndex = PHASE_INDEX[healing.phase] ?? -1;
const passed = healing.phase === "PASSED";
const failed = healing.phase === "FAILED";
$("#repairTimeline").innerHTML = TIMELINE.map((stage, index) => {
let state = "pending";
if (passed || phaseIndex > index) state = "complete";
else if (phaseIndex === index && healing.phase !== "IDLE") state = failed ? "failed" : "active";
const label = state === "complete" ? "已完成" : state === "active" ? "进行中" : state === "failed" ? "失败" : "等待";
return `<article data-state="${state}"><i></i><div><header><strong>${esc(stage.title)}</strong><span>${label}</span></header><p>${esc(timelineDetail(index, scenario, healing))}</p></div></article>`;
}).join("");
$("#repairElapsed").textContent = `${Number(healing.elapsed_s || 0).toFixed(3)} s`;
}
function renderCases() {
if (!selectedCaseId && latest.scenarios?.length) selectedCaseId = latest.scenarios[0].id;
const results = new Map((latest.batch?.results || []).map((item) => [item.case_id, item]));
$("#repairCaseList").innerHTML = (latest.scenarios || []).map((scenario, index) => {
const result = results.get(scenario.id);
const running = latest.batch?.current_case_id === scenario.id
|| (latest.healing?.case_id === scenario.id
&& !["IDLE", "PASSED", "FAILED"].includes(latest.healing.phase));
const state = result?.result === "PASS" ? "complete"
: result?.result === "FAIL" ? "failed"
: running ? "active" : "pending";
return `<button type="button" data-repair-case="${esc(scenario.id)}" data-state="${state}" class="${selectedCaseId === scenario.id ? "selected" : ""}"><span>${String(index + 1).padStart(2, "0")}</span><div><strong>${esc(scenario.short_title)}</strong><small>${esc(scenario.system)}</small></div><i></i></button>`;
}).join("");
$$('[data-repair-case]').forEach((button) => {
button.addEventListener("click", () => {
if (latest.batch?.active) return;
selectedCaseId = button.dataset.repairCase;
renderCases();
renderTimeline();
renderTwin();
});
});
const batch = latest.batch || {};
const completed = Number(batch.completed || 0);
const total = Number(batch.total || 6);
$("#repairBatchProgress").style.width = `${Math.min(100, (completed / Math.max(1, total)) * 100)}%`;
$("#repairBatchCount").textContent = `${completed} / ${total}`;
$("#repairBatchSummary").textContent = batch.active
? `正在验证 ${batch.current_case_id || "下一项"} · 通过 ${batch.passed} · 失败 ${batch.failed}`
: completed
? `批量验证完成 · 通过 ${batch.passed} · 失败 ${batch.failed}`
: "等待启动批量验证";
}
function renderTwin() {
const healing = latest.healing;
const scenario = scenarioForDisplay();
if (!scenario) return;
const telemetry = healing.telemetry || {};
const live = latest.bench_twin || {};
const bench = executionMode === "bench";
const positions = Array.isArray(telemetry.axis_positions)
? telemetry.axis_positions : [35, 42, 48, 20];
const axes = bench && live.axes?.length === 4
? live.axes
: positions.map((position, motor) => ({
motor,
label: ["水平滑轨", "坐盆升降", "靠背角度", "腿托角度"][motor],
position_pct: Number(position),
active: (telemetry.active_axes || []).includes(motor),
position_valid: telemetry.position_valid !== false,
}));
const stage = {
NORMAL_TASK: "正常任务",
FAULT_REACTION: "故障停止",
REPAIR: "执行修复",
VERIFYING: "安全复测",
RESUMING: "继续原任务",
COMPLETE: "任务已恢复",
SAFE_STOP: "安全停止",
}[telemetry.operation_stage] || healing.phase_text;
$("#repairTwinSource").textContent = bench
? (live.online ? "实物 CAN 同步" : "实物 CAN 暂离线")
: "影子动作仿真";
$("#repairTwinTask").textContent = healing.phase === "IDLE"
? `等待启动:${scenario.normal_task}` : scenario.normal_task;
$("#repairTwinStage").textContent = stage || "待机";
$("#repairTwinState").textContent = healing.phase_text;
$("#repairTwinReaction").textContent = telemetry.bench_reaction
|| (healing.phase === "IDLE" ? scenario.field_basis : scenario.fault_reaction);
$("#repairAxisStrip").innerHTML = axes.map((axis) => {
const position = Math.max(0, Math.min(100, Number(axis.position_pct || 0)));
return `<span class="${axis.active ? "active" : ""}"><b>M${axis.motor}</b><i style="--position:${position}%"></i><em>${position.toFixed(0)}%</em></span>`;
}).join("");
window.dispatchEvent(new CustomEvent("seat-healing-state", { detail: {
state: healing.phase,
axes: axes.map((axis) => ({
motor: axis.motor,
position_pct: Number(axis.position_pct || 0),
active: Boolean(axis.active),
valid: axis.position_valid !== false,
})),
ventilation: Boolean((bench ? live.thermal : telemetry).fan_running),
heatingLevel: Number((bench ? live.thermal : telemetry).heater_level || 0),
} }));
}
function connectorRows(name, pins) {
if (name === "JA1") return Array.from({ length: 7 }, (_, index) => [index + 1, index + 8]);
if (name === "JB1") return Array.from({ length: 9 }, (_, index) => [(index + 1) * 2, index * 2 + 1]);
return Array.from({ length: 20 }, (_, index) => [index + 1, index + 21]);
}
function pinButton(pin) {
if (!pin) return "<span></span>";
const state = pin.enabled === false ? "NOT_USED" : pin.state;
return `<button type="button" data-repair-pin="${esc(pin.id)}" data-state="${esc(state)}" class="${selectedPinId === pin.id ? "selected" : ""}" aria-label="${esc(`${pin.id} ${pin.net} ${STATE_TEXT[state] || state}`)}"><b>${String(pin.number).padStart(2, "0")}</b><small>${esc(pin.net)}</small></button>`;
}
function renderConnectors(hardware) {
const connectors = hardware.connectors || {};
const names = ["JA1", "JB1", "JC1"];
$("#repairConnectors").innerHTML = names.map((name) => {
const pins = connectors[name] || [];
const byNumber = new Map(pins.map((pin) => [Number(pin.number), pin]));
const ok = pins.filter((pin) => pin.state === "OK").length;
const abnormal = pins.filter((pin) => ["WARN", "FAULT"].includes(pin.state)).length;
return `<section class="v2-repair-connector" data-connector="${name}"><header><strong>${name}</strong><span>已验 ${ok} · 异常 ${abnormal}</span></header><div class="v2-repair-connector-body">${name === "JC1" ? "<i class=\"keying k1\"></i><i class=\"keying k2\"></i><i class=\"keying k3\"></i><i class=\"keying k4\"></i>" : ""}<div>${connectorRows(name, pins).map((row) => `<span>${pinButton(byNumber.get(row[0]))}${pinButton(byNumber.get(row[1]))}</span>`).join("")}</div></div></section>`;
}).join("");
$$('[data-repair-pin]').forEach((button) => button.addEventListener("click", () => {
selectedPinId = button.dataset.repairPin;
renderHardware();
}));
}
function renderPinDetail(hardware) {
const allPins = Object.values(hardware.connectors || {}).flat();
const pin = allPins.find((item) => item.id === selectedPinId) || allPins[0];
if (!pin) return;
selectedPinId = pin.id;
const state = pin.enabled === false ? "NOT_USED" : pin.state;
$("#repairPinTitle").textContent = pin.id;
$("#repairPinState").textContent = STATE_TEXT[state] || state;
$("#repairPinState").dataset.state = state;
$("#repairPinDetail").innerHTML = `
<dl><div><dt>原理图网络</dt><dd>${esc(pin.net)}</dd></div><div><dt>座椅侧功能</dt><dd>${esc(pin.function)}</dd></div><div><dt>板内二级溯源</dt><dd>${esc(pin.mcu || "—")}</dd></div><div><dt>本工程启用</dt><dd>${pin.enabled === false ? "否" : "是"}</dd></div></dl>
<section><h3>判定依据</h3><ul>${(pin.evidence || ["等待诊断证据"]).map((item) => `<li>${esc(item)}</li>`).join("")}</ul></section>
<section><h3>建议检查顺序</h3><ol>${(pin.actions || ["启动硬件诊断后生成建议"]).map((item) => `<li>${esc(item)}</li>`).join("")}</ol></section>`;
}
function renderHardware() {
const hardware = latest.hardware || {};
const session = latest.hardware_session || {};
const bridge = latest.bridge || {};
const manager = latest.bridge_manager || {};
const layers = [
["1", "ZCANPRO桥接", bridge.online ? "正常" : "离线", bridge.online ? "OK" : "UNKNOWN"],
["2", "CAN设备 / 通道", bridge.bus?.opened ? "设备与通道已打开" : "未连接", bridge.bus?.opened ? "OK" : "UNKNOWN"],
["3", "总线实时流量", bridge.bus_traffic ? "正常" : "未检测", bridge.bus_traffic ? "OK" : "WARN"],
["4", "座椅ECU协议", bridge.protocol_online ? "0x301~0x305在线" : "未检测", bridge.protocol_online ? "OK" : "UNKNOWN"],
];
$("#repairLinkLayers").innerHTML = layers.map((item) => `<div data-state="${item[3]}"><small>${item[0]} · ${item[1]}</small><strong>${item[2]}</strong></div>`).join("");
$("#repairBridgeMessage").textContent = manager.message || "等待桥接状态";
$("#repairLinkDetail").textContent = bridge.protocol_online
? `协议在线 · 最近帧 ${(bridge.recent_frame_ids || []).join(" / ")}`
: bridge.bus_traffic
? "CAN 有实时流量,但尚未识别本工程 0x301~0x305 协议。"
: "尚未收到实时 CAN 帧,启用针脚保持未检测状态,不表示针脚故障。";
$("#repairHardwareGroups").innerHTML = (hardware.groups || []).map((group) => `<button type="button" data-state="${esc(group.state)}"><i></i><div><strong>${esc(group.name)}</strong><small>${esc(group.summary)}</small></div><span>${esc(STATE_TEXT[group.state] || group.state)}</span></button>`).join("");
$("#repairHardwareOverall").textContent = `已验证 ${hardware.connector_ok_count || 0}/21 · 异常 ${(hardware.connector_fault_count || 0) + (hardware.connector_warn_count || 0)}`;
const toggle = $("#repairHardwareToggle");
toggle.innerHTML = session.active
? '<i data-lucide="square"></i>停止硬件诊断'
: '<i data-lucide="activity"></i>启动硬件诊断';
renderConnectors(hardware);
renderPinDetail(hardware);
const events = session.events || [];
$("#repairHardwareRecordsBody").innerHTML = `<p class="v2-repair-report-path">${esc(session.report_path || "停止诊断后自动保存硬件报告")}</p>${events.length ? `<table><thead><tr><th>时间</th><th>支路</th><th>状态</th><th>摘要</th></tr></thead><tbody>${[...events].reverse().map((event) => `<tr><td>${esc(event.time)}</td><td>${esc(event.name)}</td><td>${esc(STATE_TEXT[event.state] || event.state)}</td><td>${esc(event.summary)}</td></tr>`).join("")}</tbody></table>` : "<p>尚无硬件诊断事件。</p>"}`;
}
function renderGlobal() {
const bridge = latest.bridge || {};
const manager = latest.bridge_manager || {};
const healing = latest.healing || {};
const integration = latest.integration || {};
$("#repairCanStatus").textContent = bridge.online ? "在线" : "离线";
$("#repairCanDot").dataset.state = bridge.online ? "online" : manager.state === "STARTING" ? "working" : "offline";
$("#repairProtocolStatus").textContent = bridge.protocol_online ? "协议在线" : "未检测";
$("#repairProtocolDot").dataset.state = bridge.protocol_online ? "online" : "offline";
$("#repairAiStatus").textContent = healing.phase_text || "等待验证";
$("#repairAiDot").dataset.state = ["PASSED"].includes(healing.phase) ? "online" : ["IDLE"].includes(healing.phase) ? "offline" : "working";
$("#repairConnectionText").textContent = bridge.online ? "CAN 桥接在线" : manager.message || "CAN 桥接离线";
$(".v2-repair-footer").classList.toggle("offline", !bridge.online);
const benchOption = $('#repairExecutionMode option[value="bench"]');
if (benchOption) benchOption.disabled = !integration.zcanpro_configured;
if (executionMode === "bench" && !integration.zcanpro_configured) {
executionMode = "shadow";
$("#repairExecutionMode").value = "shadow";
sessionStorage.setItem(MODE_KEY, executionMode);
}
$("#repairExecutionState").textContent = executionMode === "bench" ? "HIL" : "SAFE";
$("#repairConfigNote").textContent = executionMode === "bench"
? "真实台架动作受本机 admin、安全确认与桥接状态约束"
: "影子仿真不会写入 ECU 或驱动实体设备";
$("#repairFirmwareFlash").disabled = !integration.firmware_tools_configured;
window.CognitiveAgentConsoleV2?.enhanceSelect($("#repairExecutionMode"));
}
function renderRecords() {
const results = latest.batch?.results || [];
const events = latest.healing?.events || [];
$("#repairSoftwareRecordsBody").innerHTML = `<p class="v2-repair-report-path">${esc(latest.latest_report_path || "成功用例将自动保存诊断快照")}</p>${results.length ? `<table><thead><tr><th>用例</th><th>结果</th><th>耗时</th><th>诊断码</th></tr></thead><tbody>${results.map((item) => `<tr><td>${esc(item.title)}</td><td>${esc(item.result)}</td><td>${Number(item.duration_s || 0).toFixed(2)} s</td><td>${esc(item.diagnosed_code || "—")}</td></tr>`).join("")}</tbody></table>` : "<p>尚无批量验证记录。</p>"}<div class="v2-repair-event-list">${events.map((event) => `<article><strong>${esc(event.title)}</strong><span>${esc(event.detail)}</span></article>`).join("")}</div>`;
}
function renderAll() {
renderGlobal();
renderCases();
renderTimeline();
renderTwin();
renderHardware();
renderRecords();
// Polling refreshes data only. Reapplying the active view here would also
// reset the user's current 3D orbit every 600 ms.
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
async function poll() {
if (!isActive() || polling) return;
polling = true;
try {
const response = await fetch(`${API_ROOT}/state`, {
cache: "no-store",
credentials: "same-origin",
headers: { Accept: "application/json", "X-Cognitive-Agent-Role": "admin" },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
latest = await response.json();
if (!["IDLE"].includes(latest.healing?.phase)) selectedCaseId = latest.healing.case_id;
renderAll();
} catch (error) {
$("#repairConnectionText").textContent = `自修复服务连接失败:${error.message}`;
$(".v2-repair-footer").classList.add("offline");
} finally {
polling = false;
}
}
async function runAction(action, successMessage) {
try {
await action();
toast(successMessage);
await poll();
} catch (error) {
toast(error.message, true);
}
}
function bindEvents() {
$$('[data-repair-view]').forEach((button) => button.addEventListener("click", () => showView(button.dataset.repairView)));
$("#repairExecutionMode").addEventListener("change", (event) => {
executionMode = event.target.value === "bench" ? "bench" : "shadow";
sessionStorage.setItem(MODE_KEY, executionMode);
if (latest) renderAll();
});
$("#repairStart").addEventListener("click", () => {
if (executionMode === "bench" && !window.confirm("即将向实物 ECU 写入受控故障并驱动座椅恢复。请确认运动区域无人、无遮挡且急停可用。")) return;
runAction(() => apiPost("/healing/start", { case_id: selectedCaseId, execution_mode: executionMode }), executionMode === "bench" ? "实物自修复用例已启动" : "影子故障已注入,开始自修复");
});
$("#repairRunAll").addEventListener("click", () => runAction(() => apiPost("/healing/run-all", { execution_mode: executionMode }), "六项自动验证已启动"));
$("#repairReset").addEventListener("click", () => {
if (executionMode === "bench" && !window.confirm("四轴将依次回到机械零位。请确认运动区域无人、无遮挡且急停可用。")) return;
runAction(() => apiPost("/healing/reset", { execution_mode: executionMode }), executionMode === "bench" ? "四轴归零请求已提交" : "自修复验证已重置");
});
$("#repairFirmwareFlash").addEventListener("click", () => {
if (!window.confirm("即将构建并烧录台架测试固件。请确认座椅运动区域无人、急停可用且 CMSIS-DAP 已连接。")) return;
runAction(() => apiPost("/firmware/build-flash", { confirmed_bench_clear: true }), "固件构建/烧录流程已完成");
});
$("#repairHardwareToggle").addEventListener("click", () => {
if (!latest) return;
if (latest.hardware_session?.active) {
runAction(() => apiPost("/hardware/stop"), "硬件诊断已停止并保存报告");
return;
}
if (!window.confirm("硬件诊断可能自动烧录测试固件,并短时驱动四路电机、风扇与低档加热。请确认台架区域安全且急停可用。")) return;
runAction(() => apiPost("/hardware/start", { confirmed_bench_clear: true }), "21 针主动硬件诊断已启动");
});
$("#repairHardwareSelfTest").addEventListener("click", () => runAction(() => apiPost("/hardware/self-test"), "72 针映射和诊断规则自检完成"));
$("#repairEmergencyStop").addEventListener("click", () => {
if (!window.confirm("确认提交 STOP ALL 和热管理关闭请求?")) return;
runAction(() => apiPost("/emergency-stop"), "紧急停止请求已提交");
});
$("#repairSoftwareRecords").addEventListener("click", () => $("#repairSoftwareDialog").showModal());
$("#repairHardwareRecords").addEventListener("click", () => $("#repairHardwareDialog").showModal());
$$('[data-repair-close]').forEach((button) => button.addEventListener("click", () => $(`#${button.dataset.repairClose}`).close()));
window.addEventListener("cognitive-agent:module-change", (event) => {
if (event.detail?.module === "self-repair") poll();
});
window.addEventListener("cognitive-agent:demo-role-change", () => {
if (isActive()) poll();
});
}
function init() {
if (!buildShell()) return;
bindEvents();
showView(activeView);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
if (isActive()) poll();
window.setInterval(poll, 600);
}
document.addEventListener("DOMContentLoaded", init);
}());
+15 -4
View File
@@ -1,14 +1,25 @@
const CACHE_VERSION = "cognitive-agent-pad-v13";
const CACHE_VERSION = "cognitive-agent-pad-v39";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
"/",
"/index.html",
"/styles.css?v=android-pad-3",
"/styles.css?v=android-pad-4",
"/network.js?v=android-pad-1",
"/app.js?v=seat-hil-13",
"/gesture-control.js?v=android-pad-1",
"/app.js?v=seat-hil-43",
"/gesture-control.js?v=android-pad-6",
"/pwa.js?v=android-pad-1",
"/console-v2.css?v=desktop-refactor-68",
"/console-v2.js?v=desktop-refactor-28",
"/self-extension.css?v=extension-9",
"/self-extension.js?v=extension-6",
"/self-calibration.css?v=calibration-4",
"/self-calibration.js?v=calibration-2",
"/self-repair.css?v=repair-15",
"/self-repair.js?v=repair-15",
"/self-repair-seat3d.js?v=repair-15",
"/assets/self-calibration-model.bundle.js",
"/assets/models/n7_seat.glb",
"/manifest.webmanifest",
"/icons/app-icon-192.png",
"/icons/app-icon-512.png",
-48
View File
@@ -4410,54 +4410,6 @@ pre {
color: #e0f8ff;
}
.hardware-access-field {
padding: 12px;
border: 1px solid rgba(38, 102, 159, 0.24);
border-radius: 7px;
background: rgba(38, 102, 159, 0.055);
}
.hardware-access-field input {
width: 100%;
min-height: 40px;
margin-top: 8px;
padding: 9px 11px;
color: var(--ink);
background: white;
border: 1px solid var(--line-strong);
border-radius: 6px;
font: inherit;
}
.hardware-access-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.hardware-access-actions button {
min-height: 38px;
border: 0;
border-radius: 5px;
color: white;
background: var(--blue);
font: inherit;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.hardware-access-actions button.secondary {
color: var(--ink);
background: white;
border: 1px solid var(--line-strong);
}
.hardware-access-status[data-state="approved"] { color: var(--teal); font-weight: 750; }
.hardware-access-status[data-state="pending"] { color: #9a6800; font-weight: 750; }
.hardware-access-status[data-state="expired"] { color: var(--danger); font-weight: 750; }
/* Android PAD, touch and installable-app refinements ---------------------- */
.execution-mode-options {
grid-template-columns: repeat(3, minmax(0, 1fr));