feat: refactor vehicle and gesture control console
This commit is contained in:
+1002
-56
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,667 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const pageDefinitions = [
|
||||
{ index: 0, label: "整车联合数字孪生", shortLabel: "数字孪生", target: ".joint-twin-shell" },
|
||||
{ index: 1, label: "决策轨迹与校验", shortLabel: "决策校验", target: "#panel-trace" },
|
||||
{ index: 2, label: "指令配置与硬件映射", shortLabel: "配置映射", target: "#panel-config" },
|
||||
{ index: 3, label: "CAN 编译计划", shortLabel: "CAN 计划", target: "#panel-plan" },
|
||||
{ index: 4, label: "执行结果与硬件回读", shortLabel: "执行回读", target: "#panel-result" },
|
||||
];
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||
|
||||
const ui = {
|
||||
role: sessionStorage.getItem("cognitive-agent.demo-role") === "admin" ? "admin" : "user",
|
||||
controlMode: sessionStorage.getItem("cognitive-agent.control-mode") === "gesture"
|
||||
? "gesture"
|
||||
: "natural",
|
||||
activePage: 0,
|
||||
sidebarCollapsed: sessionStorage.getItem("cognitive-agent.sidebar-collapsed") === "true",
|
||||
};
|
||||
|
||||
const dropdownControls = new Map();
|
||||
|
||||
function make(tag, className, html) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (html != null) node.innerHTML = html;
|
||||
return node;
|
||||
}
|
||||
|
||||
function closeCustomDropdowns(except = null) {
|
||||
dropdownControls.forEach((control) => {
|
||||
if (control !== except) control.close();
|
||||
});
|
||||
}
|
||||
|
||||
function enhanceSelect(select) {
|
||||
if (!select || dropdownControls.has(select)) return;
|
||||
const shell = select.parentElement;
|
||||
if (!shell) return;
|
||||
|
||||
shell.classList.add("v2-custom-select");
|
||||
select.classList.add("v2-native-select");
|
||||
|
||||
const trigger = make("button", "v2-dropdown-trigger", `
|
||||
<span class="v2-dropdown-value"></span>
|
||||
<span class="v2-dropdown-caret" aria-hidden="true"></span>`);
|
||||
trigger.type = "button";
|
||||
trigger.setAttribute("aria-haspopup", "listbox");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.setAttribute("aria-label", select.getAttribute("aria-label") || "打开下拉菜单");
|
||||
|
||||
const menu = make("div", "v2-dropdown-menu", "");
|
||||
menu.id = `${select.id}Menu`;
|
||||
menu.hidden = true;
|
||||
menu.setAttribute("role", "listbox");
|
||||
menu.setAttribute("aria-label", select.getAttribute("aria-label") || "选项");
|
||||
trigger.setAttribute("aria-controls", menu.id);
|
||||
shell.append(trigger, menu);
|
||||
|
||||
const valueNode = $(".v2-dropdown-value", trigger);
|
||||
const render = () => {
|
||||
const selected = select.selectedOptions[0] || select.options[0];
|
||||
valueNode.textContent = selected?.textContent?.trim() || "请选择";
|
||||
trigger.disabled = select.disabled;
|
||||
menu.innerHTML = "";
|
||||
[...select.options].forEach((option) => {
|
||||
const item = make("button", "v2-dropdown-option", "");
|
||||
item.type = "button";
|
||||
item.dataset.value = option.value;
|
||||
item.textContent = option.textContent.trim();
|
||||
item.disabled = option.disabled;
|
||||
item.setAttribute("role", "option");
|
||||
item.setAttribute("aria-selected", String(option.value === select.value));
|
||||
menu.append(item);
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
menu.hidden = true;
|
||||
shell.classList.remove("v2-dropdown-open");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (trigger.disabled) return;
|
||||
closeCustomDropdowns();
|
||||
render();
|
||||
menu.hidden = false;
|
||||
shell.classList.add("v2-dropdown-open");
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
};
|
||||
|
||||
const control = { close, menu, open, select, shell, sync: render, trigger };
|
||||
dropdownControls.set(select, control);
|
||||
|
||||
trigger.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
if (menu.hidden) open();
|
||||
else close();
|
||||
});
|
||||
trigger.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
open();
|
||||
requestAnimationFrame(() => {
|
||||
$(".v2-dropdown-option[aria-selected=\"true\"]:not(:disabled)", menu)?.focus();
|
||||
});
|
||||
} else if (event.key === "Escape") {
|
||||
close();
|
||||
}
|
||||
});
|
||||
menu.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const item = event.target.closest(".v2-dropdown-option");
|
||||
if (!item || item.disabled) return;
|
||||
select.value = item.dataset.value;
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
render();
|
||||
close();
|
||||
trigger.focus();
|
||||
});
|
||||
menu.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
close();
|
||||
trigger.focus();
|
||||
});
|
||||
select.addEventListener("change", render);
|
||||
new MutationObserver(render).observe(select, {
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
function enhanceConfiguredDropdowns() {
|
||||
[$("#v2RoleSelect"), $("#vehicleSelect"), $("#v2ExecutionSelect")]
|
||||
.filter(Boolean)
|
||||
.forEach(enhanceSelect);
|
||||
}
|
||||
|
||||
function buildSidebar(commandPanel) {
|
||||
commandPanel.classList.add("v2-sidebar");
|
||||
commandPanel.innerHTML = "";
|
||||
|
||||
const profile = make("section", "v2-profile", `
|
||||
<div class="v2-avatar" aria-hidden="true"><span></span></div>
|
||||
<div class="v2-profile-copy">
|
||||
<small>WELCOME TO THE CABIN</small>
|
||||
<div class="v2-role-select">
|
||||
<span>Hi,</span>
|
||||
<select id="v2RoleSelect" aria-label="切换演示权限">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="v2-sidebar-toggle" id="v2SidebarToggle" type="button" aria-label="折叠左侧控制台" aria-expanded="true">
|
||||
<i data-lucide="panel-left-close"></i>
|
||||
</button>`);
|
||||
|
||||
const control = make("section", "v2-sidebar-section v2-control-section", `
|
||||
<div class="v2-section-heading">
|
||||
<span>CONTROL DESK</span>
|
||||
<h2>控制台</h2>
|
||||
</div>
|
||||
<span class="v2-field-caption">控制方式</span>
|
||||
<div class="v2-control-switch" role="group" aria-label="控制方式">
|
||||
<button type="button" data-v2-control="natural"><i data-lucide="message-square-text"></i><span>自然语言控制</span></button>
|
||||
<button type="button" data-v2-control="gesture"><i data-lucide="scan-face"></i><span>手势控制</span></button>
|
||||
</div>`);
|
||||
|
||||
const config = make("section", "v2-sidebar-section v2-config-section", `
|
||||
<div class="v2-divider"></div>
|
||||
<span class="v2-field-caption">配置中心</span>
|
||||
<div class="v2-config-stack" id="v2ConfigStack"></div>`);
|
||||
|
||||
const footer = make("footer", "v2-sidebar-footer", `
|
||||
<span class="v2-live-dot"></span>
|
||||
<div><strong>智舱控制链路</strong><small id="v2ConnectionText">正在连接服务</small></div>
|
||||
<button type="button" id="v2LogoutButton" title="退出登录" aria-label="退出登录"><i data-lucide="log-out"></i></button>`);
|
||||
|
||||
commandPanel.append(profile, control, config, footer);
|
||||
$("#v2RoleSelect").value = ui.role;
|
||||
return $("#v2ConfigStack");
|
||||
}
|
||||
|
||||
function buildConfigFields(configStack, originalForm) {
|
||||
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");
|
||||
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>
|
||||
<p class="provider-note" id="v2ModelNote">模型由服务端安全配置</p>`);
|
||||
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>
|
||||
<small class="v2-gesture-model-note" id="v2GestureModelNote">Tasks Vision · 0.10.14</small>`);
|
||||
configStack.append(gestureModelField);
|
||||
|
||||
const userHardwareState = make("div", "v2-user-target-status", `
|
||||
<i></i><span id="v2UserTargetText">正在检查真实台架</span>`);
|
||||
configStack.append(userHardwareState);
|
||||
|
||||
if (providerField) {
|
||||
providerField.classList.add("v2-provider-field");
|
||||
providerField.hidden = true;
|
||||
configStack.append(providerField);
|
||||
}
|
||||
|
||||
const executionSelect = make("div", "v2-config-field v2-execution-field", `
|
||||
<label class="field-label" for="v2ExecutionSelect">执行目标</label>
|
||||
<div class="v2-select-shell">
|
||||
<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>
|
||||
<p class="provider-note" id="v2ExecutionNote">管理员可切换执行环境</p>`);
|
||||
configStack.append(executionSelect);
|
||||
|
||||
if (executionField) {
|
||||
executionField.classList.add("v2-native-execution-field");
|
||||
executionField.hidden = true;
|
||||
configStack.append(executionField);
|
||||
}
|
||||
|
||||
const advanced = make("div", "v2-advanced-stack", "");
|
||||
[hardwareAccessField, simulationFaultField, approvalField].forEach((field) => {
|
||||
if (!field) return;
|
||||
field.classList.add("v2-config-field", "v2-advanced-field");
|
||||
advanced.append(field);
|
||||
});
|
||||
configStack.append(advanced);
|
||||
}
|
||||
|
||||
function buildOperation(mainPanel, originalForm, panelHeading, modeRail, requestHeader, gestureDrawer) {
|
||||
const operation = make("section", "v2-operation-shell", `
|
||||
<div class="v2-operation-glow" aria-hidden="true"></div>
|
||||
<div class="v2-natural-panel" id="v2NaturalPanel"></div>
|
||||
<div class="v2-gesture-panel" id="v2GesturePanel"></div>`);
|
||||
mainPanel.prepend(operation);
|
||||
|
||||
const naturalPanel = $("#v2NaturalPanel");
|
||||
if (panelHeading) {
|
||||
panelHeading.classList.add("v2-operation-heading");
|
||||
const kicker = $(".section-kicker", panelHeading);
|
||||
const title = $("h2", panelHeading);
|
||||
const intro = $(".panel-intro", panelHeading);
|
||||
if (kicker) kicker.textContent = "自然语言控制";
|
||||
if (title) title.textContent = "一句话,调好整车";
|
||||
if (intro) intro.textContent = "座椅、空调、天幕与氛围灯,一句话联动完成";
|
||||
naturalPanel.append(panelHeading);
|
||||
}
|
||||
|
||||
const naturalGrid = make("div", "v2-natural-grid", "");
|
||||
originalForm.classList.add("v2-request-form");
|
||||
naturalGrid.append(originalForm);
|
||||
if (requestHeader) {
|
||||
requestHeader.classList.add("v2-request-status");
|
||||
naturalGrid.append(requestHeader);
|
||||
}
|
||||
naturalPanel.append(naturalGrid);
|
||||
if (modeRail) {
|
||||
modeRail.classList.add("v2-mode-rail");
|
||||
naturalPanel.append(modeRail);
|
||||
}
|
||||
|
||||
const gesturePanel = $("#v2GesturePanel");
|
||||
const gestureHeading = make("header", "v2-operation-heading v2-gesture-operation-heading", `
|
||||
<span class="section-kicker">手势控制</span>
|
||||
<h2>抬抬手,控制整车</h2>
|
||||
<p class="panel-intro">通过摄像头识别手势,直观控制车舱功能</p>`);
|
||||
gesturePanel.append(gestureHeading);
|
||||
if (gestureDrawer) {
|
||||
gestureDrawer.classList.add("v2-inline-gesture");
|
||||
const cameraStage = $(".gesture-camera-stage", gestureDrawer);
|
||||
const cameraToggle = $("#gestureStartButton", gestureDrawer);
|
||||
if (cameraStage && cameraToggle) {
|
||||
cameraToggle.classList.add("v2-camera-toggle");
|
||||
cameraToggle.setAttribute("aria-label", "开启摄像头");
|
||||
cameraToggle.setAttribute("title", "开启摄像头");
|
||||
cameraStage.append(cameraToggle);
|
||||
}
|
||||
gesturePanel.append(gestureDrawer);
|
||||
}
|
||||
return operation;
|
||||
}
|
||||
|
||||
function buildOutput(mainPanel) {
|
||||
const output = make("section", "v2-output-shell", `
|
||||
<header class="v2-output-heading">
|
||||
<div>
|
||||
<span class="v2-page-index" id="v2PageIndex">00</span>
|
||||
<div><small>INFORMATION OUTPUT</small><h2 id="v2PageTitle">整车联合数字孪生</h2></div>
|
||||
</div>
|
||||
<span class="v2-output-state"><i></i><span id="v2OutputState">LIVE STATE</span></span>
|
||||
</header>
|
||||
<div class="v2-output-content" id="v2OutputContent"></div>
|
||||
<nav class="v2-page-rail" id="v2PageRail" aria-label="管理员信息分页"></nav>`);
|
||||
mainPanel.append(output);
|
||||
|
||||
const content = $("#v2OutputContent");
|
||||
const twin = $(".joint-twin-shell", mainPanel);
|
||||
const resultWorkspace = $(".result-workspace", mainPanel);
|
||||
if (twin) content.append(twin);
|
||||
if (resultWorkspace) content.append(resultWorkspace);
|
||||
|
||||
pageDefinitions.forEach((page) => {
|
||||
const target = $(page.target, content);
|
||||
if (!target) return;
|
||||
target.dataset.v2Page = String(page.index);
|
||||
target.classList.add("v2-output-page");
|
||||
});
|
||||
|
||||
pageDefinitions.forEach((page) => {
|
||||
const button = make("button", "v2-page-button", `
|
||||
<span>${String(page.index).padStart(2, "0")}</span>
|
||||
<i></i>
|
||||
<strong>${page.shortLabel}</strong>`);
|
||||
button.type = "button";
|
||||
button.dataset.v2PageTarget = String(page.index);
|
||||
button.setAttribute("aria-label", `${String(page.index).padStart(2, "0")} ${page.label}`);
|
||||
$("#v2PageRail").append(button);
|
||||
});
|
||||
}
|
||||
|
||||
function setPage(pageIndex) {
|
||||
const maxPage = ui.role === "admin" ? pageDefinitions.length - 1 : 0;
|
||||
ui.activePage = Math.max(0, Math.min(Number(pageIndex) || 0, maxPage));
|
||||
const definition = pageDefinitions[ui.activePage];
|
||||
$(".v2-output-shell")?.classList.toggle("v2-twin-active", definition.index === 0);
|
||||
$$(".v2-output-page").forEach((page) => {
|
||||
const active = Number(page.dataset.v2Page) === ui.activePage;
|
||||
page.classList.toggle("v2-active-output", active);
|
||||
page.setAttribute("aria-hidden", active ? "false" : "true");
|
||||
});
|
||||
$$(".v2-page-button").forEach((button) => {
|
||||
const active = Number(button.dataset.v2PageTarget) === ui.activePage;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-current", active ? "page" : "false");
|
||||
});
|
||||
$("#v2PageIndex").textContent = String(definition.index).padStart(2, "0");
|
||||
$("#v2PageTitle").textContent = definition.label;
|
||||
$("#v2OutputState").textContent = definition.index === 0 ? "LIVE STATE" : "AUDIT VIEW";
|
||||
requestAnimationFrame(updateScrollCues);
|
||||
}
|
||||
|
||||
function updateScrollCues() {
|
||||
$$(".result-workspace .tab-panel").forEach((panel) => {
|
||||
const active = panel.classList.contains("v2-active-output");
|
||||
panel.classList.toggle(
|
||||
"v2-scrollable",
|
||||
active && panel.scrollHeight > panel.clientHeight + 6,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function updateModeEdgeHints() {
|
||||
const chips = $(".v2-mode-rail .mode-chips");
|
||||
if (!chips) return;
|
||||
const buttons = $$('button', chips);
|
||||
buttons.forEach((button) => {
|
||||
button.classList.remove("v2-edge-fade-left", "v2-edge-fade-right");
|
||||
button.style.removeProperty("--v2-edge-fade-start");
|
||||
button.style.removeProperty("--v2-edge-fade-end");
|
||||
});
|
||||
if (buttons.length < 2 || chips.clientWidth <= 0) return;
|
||||
|
||||
const viewport = chips.getBoundingClientRect();
|
||||
const visible = buttons.map((button) => ({
|
||||
button,
|
||||
rect: button.getBoundingClientRect(),
|
||||
})).filter(({ rect }) => {
|
||||
return rect.right > viewport.left + 1 && rect.left < viewport.right - 1;
|
||||
});
|
||||
if (!visible.length) return;
|
||||
|
||||
const fadeSpan = 30;
|
||||
const firstVisible = visible[0];
|
||||
const lastVisible = visible[visible.length - 1];
|
||||
if (firstVisible.rect.left < viewport.left - 1) {
|
||||
const clippedAt = Math.min(100, Math.max(
|
||||
0,
|
||||
((viewport.left - firstVisible.rect.left) / firstVisible.rect.width) * 100,
|
||||
));
|
||||
firstVisible.button.style.setProperty("--v2-edge-fade-start", `${clippedAt}%`);
|
||||
firstVisible.button.style.setProperty(
|
||||
"--v2-edge-fade-end",
|
||||
`${Math.min(100, clippedAt + fadeSpan)}%`,
|
||||
);
|
||||
firstVisible.button.classList.add("v2-edge-fade-left");
|
||||
}
|
||||
if (lastVisible.rect.right > viewport.right + 1) {
|
||||
const clippedAt = Math.min(100, Math.max(
|
||||
0,
|
||||
((viewport.right - lastVisible.rect.left) / lastVisible.rect.width) * 100,
|
||||
));
|
||||
lastVisible.button.style.setProperty(
|
||||
"--v2-edge-fade-start",
|
||||
`${Math.max(0, clippedAt - fadeSpan)}%`,
|
||||
);
|
||||
lastVisible.button.style.setProperty("--v2-edge-fade-end", `${clippedAt}%`);
|
||||
lastVisible.button.classList.add("v2-edge-fade-right");
|
||||
}
|
||||
}
|
||||
|
||||
function setControlMode(mode, { triggerGesture = true } = {}) {
|
||||
ui.controlMode = mode === "gesture" ? "gesture" : "natural";
|
||||
sessionStorage.setItem("cognitive-agent.control-mode", ui.controlMode);
|
||||
document.body.dataset.controlMode = ui.controlMode;
|
||||
$$('[data-v2-control]').forEach((button) => {
|
||||
const active = button.dataset.v2Control === ui.controlMode;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
requestAnimationFrame(updateModeEdgeHints);
|
||||
const drawer = $("#gestureDrawer");
|
||||
if (!triggerGesture) {
|
||||
const gestureVisible = ui.controlMode === "gesture";
|
||||
drawer?.setAttribute("aria-hidden", String(!gestureVisible));
|
||||
$("#gestureToggleButton")?.setAttribute("aria-expanded", String(gestureVisible));
|
||||
return;
|
||||
}
|
||||
if (ui.controlMode === "gesture") {
|
||||
if (!drawer?.classList.contains("open")) $("#gestureToggleButton")?.click();
|
||||
} else if (drawer?.classList.contains("open")) {
|
||||
$("#gestureCloseButton")?.click();
|
||||
}
|
||||
}
|
||||
|
||||
function setRole(role) {
|
||||
ui.role = role === "admin" ? "admin" : "user";
|
||||
sessionStorage.setItem("cognitive-agent.demo-role", ui.role);
|
||||
document.body.dataset.demoRole = ui.role;
|
||||
window.dispatchEvent(new CustomEvent("cognitive-agent:demo-role-change", {
|
||||
detail: { role: ui.role },
|
||||
}));
|
||||
$("#v2RoleSelect").value = ui.role;
|
||||
dropdownControls.get($("#v2RoleSelect"))?.sync();
|
||||
$("#v2PageRail").hidden = ui.role !== "admin";
|
||||
if (ui.role !== "admin") {
|
||||
setPage(0);
|
||||
requestUserHardwareMode();
|
||||
} else {
|
||||
const note = $("#v2ExecutionNote");
|
||||
if (note) note.textContent = "管理员可切换虚拟 CAN、模拟台架与真实台架";
|
||||
const runButton = $("#runButton");
|
||||
if (runButton && !runButton.classList.contains("loading")) runButton.disabled = false;
|
||||
syncExecutionSelector();
|
||||
}
|
||||
}
|
||||
|
||||
function setSidebarCollapsed(collapsed) {
|
||||
ui.sidebarCollapsed = Boolean(collapsed);
|
||||
sessionStorage.setItem(
|
||||
"cognitive-agent.sidebar-collapsed",
|
||||
String(ui.sidebarCollapsed),
|
||||
);
|
||||
document.body.classList.toggle("v2-sidebar-collapsed", ui.sidebarCollapsed);
|
||||
const button = $("#v2SidebarToggle");
|
||||
if (button) {
|
||||
button.setAttribute("aria-expanded", String(!ui.sidebarCollapsed));
|
||||
button.setAttribute(
|
||||
"aria-label",
|
||||
ui.sidebarCollapsed ? "展开左侧控制台" : "折叠左侧控制台",
|
||||
);
|
||||
button.innerHTML = `<i data-lucide="${ui.sidebarCollapsed ? "panel-left-open" : "panel-left-close"}"></i>`;
|
||||
}
|
||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 180);
|
||||
}
|
||||
|
||||
function requestUserHardwareMode() {
|
||||
const hardwareButton = $("#hardwareMode");
|
||||
const note = $("#v2ExecutionNote");
|
||||
const userTarget = $(".v2-user-target-status");
|
||||
const userTargetText = $("#v2UserTargetText");
|
||||
const runButton = $("#runButton");
|
||||
if (!hardwareButton) return;
|
||||
if (!hardwareButton.disabled) {
|
||||
if (!hardwareButton.classList.contains("active")) hardwareButton.click();
|
||||
if (note) note.textContent = "user 演示固定使用真实台架";
|
||||
if (userTarget) userTarget.dataset.state = "ready";
|
||||
if (userTargetText) userTargetText.textContent = "执行目标:真实台架";
|
||||
if (runButton && !runButton.classList.contains("loading")) runButton.disabled = false;
|
||||
} else if (note) {
|
||||
note.textContent = "真实台架尚未就绪,服务端安全门禁保持锁定";
|
||||
if (userTarget) userTarget.dataset.state = "locked";
|
||||
if (userTargetText) userTargetText.textContent = "真实台架尚未就绪";
|
||||
if (runButton) runButton.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function syncExecutionSelector() {
|
||||
const select = $("#v2ExecutionSelect");
|
||||
if (!select) return;
|
||||
const active = $('[data-execution-mode].active');
|
||||
if (active) select.value = active.dataset.executionMode;
|
||||
["dry_run", "simulated_hardware", "hardware"].forEach((value) => {
|
||||
const option = $(`option[value="${value}"]`, select);
|
||||
const button = $(`[data-execution-mode="${value}"]`);
|
||||
if (option && button) option.disabled = button.disabled;
|
||||
});
|
||||
dropdownControls.get(select)?.sync();
|
||||
}
|
||||
|
||||
function syncRuntimePresentation() {
|
||||
const modelBadge = $("#modelBadge");
|
||||
const connectionBadge = $("#connectionBadge");
|
||||
const modelValue = $("#v2ModelValue");
|
||||
const modelNote = $("#v2ModelNote");
|
||||
const connectionText = $("#v2ConnectionText");
|
||||
if (modelBadge && modelValue) {
|
||||
const text = modelBadge.textContent.trim() || "模型检测中";
|
||||
modelValue.textContent = text;
|
||||
const unavailable = modelBadge.classList.contains("danger");
|
||||
$("#v2ModelField")?.classList.toggle("unavailable", unavailable);
|
||||
$("#v2ModelState").textContent = unavailable ? "LOCAL" : "READY";
|
||||
if (modelNote) modelNote.textContent = unavailable
|
||||
? "GPT 不可用,自动使用本地规则"
|
||||
: "服务端安全托管 · 失败自动降级";
|
||||
}
|
||||
if (connectionBadge && connectionText) {
|
||||
connectionText.textContent = connectionBadge.textContent.trim() || "正在连接服务";
|
||||
const online = !connectionBadge.classList.contains("danger")
|
||||
&& !connectionBadge.classList.contains("warning");
|
||||
$(".v2-sidebar-footer")?.classList.toggle("offline", !online);
|
||||
}
|
||||
syncExecutionSelector();
|
||||
if (ui.role === "user") requestUserHardwareMode();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
$("#v2RoleSelect")?.addEventListener("change", (event) => setRole(event.target.value));
|
||||
$$('[data-v2-control]').forEach((button) => {
|
||||
button.addEventListener("click", () => setControlMode(button.dataset.v2Control));
|
||||
});
|
||||
$("#v2ExecutionSelect")?.addEventListener("change", (event) => {
|
||||
$(`[data-execution-mode="${event.target.value}"]`)?.click();
|
||||
syncExecutionSelector();
|
||||
});
|
||||
$$(".v2-page-button").forEach((button) => {
|
||||
button.addEventListener("click", () => setPage(button.dataset.v2PageTarget));
|
||||
});
|
||||
$("#v2LogoutButton")?.addEventListener("click", () => $("#logoutButton")?.click());
|
||||
$("#v2SidebarToggle")?.addEventListener(
|
||||
"click",
|
||||
() => setSidebarCollapsed(!ui.sidebarCollapsed),
|
||||
);
|
||||
document.addEventListener("click", () => closeCustomDropdowns());
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeCustomDropdowns();
|
||||
});
|
||||
window.addEventListener("resize", updateScrollCues);
|
||||
window.addEventListener("resize", updateModeEdgeHints);
|
||||
window.addEventListener("cognitive-agent:result", () => requestAnimationFrame(updateScrollCues));
|
||||
const modeChips = $(".v2-mode-rail .mode-chips");
|
||||
modeChips?.addEventListener(
|
||||
"scroll",
|
||||
() => requestAnimationFrame(updateModeEdgeHints),
|
||||
{ passive: true },
|
||||
);
|
||||
if (modeChips) {
|
||||
new MutationObserver(() => requestAnimationFrame(updateModeEdgeHints))
|
||||
.observe(modeChips, { childList: true });
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(syncRuntimePresentation);
|
||||
[$("#modelBadge"), $("#connectionBadge"), $("#hardwareMode"), $("#simulationMode")]
|
||||
.filter(Boolean)
|
||||
.forEach((node) => observer.observe(node, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function init() {
|
||||
const workspace = $(".workspace");
|
||||
const commandPanel = $(".command-panel");
|
||||
const mainPanel = $(".main-panel");
|
||||
const originalForm = $("#requestForm");
|
||||
if (!workspace || !commandPanel || !mainPanel || !originalForm) return;
|
||||
|
||||
document.body.classList.add("console-v2");
|
||||
const panelHeading = $(".panel-heading", commandPanel);
|
||||
const modeRail = $(".mode-rail", commandPanel);
|
||||
const exampleGroup = $(".example-group", commandPanel);
|
||||
const safetyStrip = $("#safetyStrip", commandPanel);
|
||||
const requestHeader = $(".request-header", mainPanel);
|
||||
const gestureDrawer = $("#gestureDrawer");
|
||||
|
||||
const detached = {
|
||||
panelHeading,
|
||||
modeRail,
|
||||
requestHeader,
|
||||
gestureDrawer,
|
||||
originalForm,
|
||||
exampleGroup,
|
||||
safetyStrip,
|
||||
};
|
||||
Object.values(detached).filter(Boolean).forEach((node) => node.remove());
|
||||
|
||||
const legacyParking = make("div", "v2-legacy-parking v2-legacy-hidden", "");
|
||||
[exampleGroup, safetyStrip].filter(Boolean).forEach((node) => legacyParking.append(node));
|
||||
document.body.append(legacyParking);
|
||||
|
||||
const configStack = buildSidebar(commandPanel);
|
||||
buildConfigFields(configStack, originalForm);
|
||||
enhanceConfiguredDropdowns();
|
||||
buildOperation(
|
||||
mainPanel,
|
||||
originalForm,
|
||||
panelHeading,
|
||||
modeRail,
|
||||
requestHeader,
|
||||
gestureDrawer,
|
||||
);
|
||||
|
||||
$(".pipeline", mainPanel)?.classList.add("v2-legacy-hidden");
|
||||
$(".topbar")?.classList.add("v2-legacy-hidden");
|
||||
$(".gesture-backdrop")?.classList.add("v2-legacy-hidden");
|
||||
buildOutput(mainPanel);
|
||||
bindEvents();
|
||||
setControlMode(ui.controlMode, { triggerGesture: false });
|
||||
setRole(ui.role);
|
||||
setSidebarCollapsed(ui.sidebarCollapsed);
|
||||
setPage(0);
|
||||
syncRuntimePresentation();
|
||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
requestAnimationFrame(() => {
|
||||
updateModeEdgeHints();
|
||||
document.body.classList.add("v2-ready");
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
}());
|
||||
@@ -55,6 +55,10 @@ const state = {
|
||||
customMapping: loadCustomMapping(),
|
||||
customizeOpen: false,
|
||||
engine: null,
|
||||
runtimeReady: false,
|
||||
cameraStatus: "checking",
|
||||
cameraMessage: "正在检测摄像头…",
|
||||
cameraCheckSequence: 0,
|
||||
};
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
@@ -80,6 +84,91 @@ function notify(message) {
|
||||
window.setTimeout(() => toast.classList.remove("visible"), 2600);
|
||||
}
|
||||
|
||||
function cameraFailureDetails(error) {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
return window.isSecureContext === false
|
||||
? { status: "unsupported", message: "当前页面不是安全连接,浏览器已阻止摄像头访问" }
|
||||
: { status: "unsupported", message: "当前浏览器不支持摄像头访问" };
|
||||
}
|
||||
switch (error?.name) {
|
||||
case "NotFoundError":
|
||||
case "DevicesNotFoundError":
|
||||
return { status: "unavailable", message: "未检测到可用摄像头" };
|
||||
case "NotAllowedError":
|
||||
case "PermissionDeniedError":
|
||||
case "SecurityError":
|
||||
return { status: "blocked", message: "摄像头权限被拒绝,请在浏览器设置中允许访问" };
|
||||
case "NotReadableError":
|
||||
case "TrackStartError":
|
||||
return { status: "busy", message: "摄像头正被其他程序占用或暂时不可用" };
|
||||
case "OverconstrainedError":
|
||||
case "ConstraintNotSatisfiedError":
|
||||
return { status: "incompatible", message: "摄像头不支持当前画面参数" };
|
||||
case "AbortError":
|
||||
return { status: "unknown", message: "摄像头启动被中断,请重试" };
|
||||
default:
|
||||
return { status: "unknown", message: error?.message || "摄像头启动失败" };
|
||||
}
|
||||
}
|
||||
|
||||
function syncCameraButtonAvailability() {
|
||||
const button = $("#gestureStartButton");
|
||||
if (!button || state.stream) return;
|
||||
button.disabled = !state.runtimeReady
|
||||
|| ["checking", "unavailable", "unsupported"].includes(state.cameraStatus);
|
||||
}
|
||||
|
||||
function setCameraAvailability(status, message, { announce = true } = {}) {
|
||||
state.cameraStatus = status;
|
||||
state.cameraMessage = message;
|
||||
$("#gestureDrawer")?.setAttribute("data-camera-status", status);
|
||||
syncCameraButtonAvailability();
|
||||
if (announce && !state.stream) $("#gestureSafetyText").textContent = message;
|
||||
}
|
||||
|
||||
async function refreshCameraAvailability({ announce = true } = {}) {
|
||||
if (!navigator.mediaDevices?.enumerateDevices || !navigator.mediaDevices?.getUserMedia) {
|
||||
const failure = cameraFailureDetails();
|
||||
setCameraAvailability(failure.status, failure.message, { announce });
|
||||
return false;
|
||||
}
|
||||
|
||||
const checkSequence = ++state.cameraCheckSequence;
|
||||
if (!state.stream) setCameraAvailability("checking", "正在检测摄像头…", { announce });
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
if (checkSequence !== state.cameraCheckSequence) return state.cameraStatus === "available";
|
||||
const available = devices.some((device) => device.kind === "videoinput");
|
||||
setCameraAvailability(
|
||||
available ? "available" : "unavailable",
|
||||
available ? "已检测到摄像头,点击开启开始识别" : "未检测到可用摄像头",
|
||||
{ announce },
|
||||
);
|
||||
return available;
|
||||
} catch (error) {
|
||||
if (checkSequence !== state.cameraCheckSequence) return state.cameraStatus === "available";
|
||||
const failure = cameraFailureDetails(error);
|
||||
const message = failure.status === "blocked"
|
||||
? "尚未获得摄像头权限,可点击开启并在浏览器中授权"
|
||||
: "暂时无法检测摄像头,可点击开启时再次确认";
|
||||
setCameraAvailability("unknown", message, { announce });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCameraDeviceChange() {
|
||||
const streamWasActive = Boolean(state.stream);
|
||||
const available = await refreshCameraAvailability({ announce: !streamWasActive });
|
||||
const streamStillLive = state.stream?.getVideoTracks().some((track) => track.readyState === "live");
|
||||
if (streamWasActive && (available === false || !streamStillLive)) {
|
||||
const message = available === false
|
||||
? "摄像头连接已断开,未检测到其他可用摄像头"
|
||||
: "当前摄像头连接已断开,请重新开启摄像头";
|
||||
stopCamera({ message });
|
||||
notify(message);
|
||||
}
|
||||
}
|
||||
|
||||
function distance(a, b) {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
@@ -386,7 +475,6 @@ function updateEngineUI(engine, message) {
|
||||
pill.className = `gesture-state-pill ${engine.armed ? (engine.currentAction ? "moving" : "armed") : "idle"}`;
|
||||
pill.querySelector("b").textContent = engine.armed ? (engine.currentAction ? "执行中" : "已激活") : "已锁止";
|
||||
$("#gestureSafetyText").textContent = message;
|
||||
$("#gestureArmButton").textContent = engine.armed ? "已激活" : "激活控制";
|
||||
}
|
||||
|
||||
function executeGestureAction(action) {
|
||||
@@ -469,6 +557,7 @@ async function loadGestureRuntime() {
|
||||
state.runtime = await response.json();
|
||||
state.engine = new GestureEngine(state.runtime.safety, executeGestureAction);
|
||||
renderMappingGrid();
|
||||
renderGestureDebugOptions();
|
||||
if (!state.runtime.model_available) throw new Error("内置手势模型不可用,请检查工程资源");
|
||||
}
|
||||
|
||||
@@ -497,6 +586,8 @@ async function startCamera() {
|
||||
const button = $("#gestureStartButton");
|
||||
button.disabled = true;
|
||||
button.querySelector("span").textContent = "正在启动…";
|
||||
button.setAttribute("aria-label", "正在启动摄像头");
|
||||
button.setAttribute("title", "正在启动摄像头");
|
||||
try {
|
||||
if (!state.runtime) await loadGestureRuntime();
|
||||
await loadRecognizer();
|
||||
@@ -504,27 +595,30 @@ async function startCamera() {
|
||||
video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: "user" },
|
||||
audio: false,
|
||||
});
|
||||
setCameraAvailability("available", "摄像头可用,点击开启开始识别", { announce: false });
|
||||
const video = $("#gestureVideo");
|
||||
video.srcObject = state.stream;
|
||||
await video.play();
|
||||
$("#gestureCameraEmpty").hidden = true;
|
||||
$("#gestureLiveBadge").hidden = false;
|
||||
$("#gestureArmButton").disabled = false;
|
||||
$("#gestureStopButton").disabled = false;
|
||||
button.classList.add("active");
|
||||
button.querySelector("span").textContent = "关闭摄像头";
|
||||
button.setAttribute("aria-label", "关闭摄像头");
|
||||
button.setAttribute("title", "关闭摄像头");
|
||||
$("#gestureSafetyText").textContent = "张开手掌并保持 1 秒以激活";
|
||||
state.frameRequest = requestAnimationFrame(recognizeLoop);
|
||||
} catch (error) {
|
||||
$("#gestureSafetyText").textContent = error.message || "摄像头启动失败";
|
||||
notify(error.message || "摄像头启动失败");
|
||||
stopCamera();
|
||||
const failure = cameraFailureDetails(error);
|
||||
setCameraAvailability(failure.status, failure.message, { announce: false });
|
||||
stopCamera({ message: failure.message });
|
||||
notify(failure.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
syncCameraButtonAvailability();
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
function stopCamera({ message = null } = {}) {
|
||||
if (state.frameRequest) cancelAnimationFrame(state.frameRequest);
|
||||
state.frameRequest = null;
|
||||
state.stream?.getTracks().forEach((track) => track.stop());
|
||||
@@ -536,15 +630,17 @@ function stopCamera() {
|
||||
canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
$("#gestureCameraEmpty").hidden = false;
|
||||
$("#gestureLiveBadge").hidden = true;
|
||||
$("#gestureArmButton").disabled = true;
|
||||
$("#gestureStopButton").disabled = true;
|
||||
const button = $("#gestureStartButton");
|
||||
button.classList.remove("active");
|
||||
button.querySelector("span").textContent = "开启摄像头";
|
||||
button.setAttribute("aria-label", "开启摄像头");
|
||||
button.setAttribute("title", "开启摄像头");
|
||||
state.engine?.setArmed(false);
|
||||
$("#gestureDetected").textContent = "—";
|
||||
$("#gestureExpected").textContent = "等待启动";
|
||||
$("#gestureSafetyText").textContent = "摄像头尚未开启";
|
||||
$("#gestureSafetyText").textContent = message || state.cameraMessage || "摄像头尚未开启";
|
||||
syncCameraButtonAvailability();
|
||||
}
|
||||
|
||||
function toggleCamera() {
|
||||
@@ -562,6 +658,75 @@ function renderMappingGrid() {
|
||||
select.innerHTML = Object.entries(ACTIONS).map(([value, item]) => `<option value="${value}">${item.label}</option>`).join("");
|
||||
}
|
||||
|
||||
function renderGestureDebugOptions() {
|
||||
const select = $("#gestureDebugSelect");
|
||||
if (!select || !state.runtime) return;
|
||||
const tokens = [...new Set([
|
||||
"OPEN_PALM",
|
||||
"CLOSED_FIST",
|
||||
...Object.keys(state.runtime.actions || {}),
|
||||
])];
|
||||
select.innerHTML = tokens.map((token) => {
|
||||
const action = mappingForToken(token);
|
||||
const actionLabel = action ? ACTIONS[action]?.label || action : token === "OPEN_PALM" ? "激活控制" : "停止当前动作";
|
||||
return `<option value="${token}">${TOKEN_NAMES[token] || token} → ${actionLabel}</option>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function setGestureDebugOpen(open) {
|
||||
const panel = $("#gestureDebugPanel");
|
||||
const button = $("#gestureArmButton");
|
||||
if (!panel || !button) return;
|
||||
const allowed = document.body.dataset.demoRole === "admin";
|
||||
const nextOpen = allowed && open;
|
||||
panel.hidden = !nextOpen;
|
||||
button.classList.toggle("active", nextOpen);
|
||||
button.setAttribute("aria-expanded", String(nextOpen));
|
||||
}
|
||||
|
||||
function toggleGestureDebug() {
|
||||
const panel = $("#gestureDebugPanel");
|
||||
setGestureDebugOpen(panel?.hidden ?? true);
|
||||
}
|
||||
|
||||
function sendSimulatedGesture() {
|
||||
if (document.body.dataset.demoRole !== "admin") {
|
||||
setGestureDebugOpen(false);
|
||||
notify("仅管理员可发送模拟手势");
|
||||
return;
|
||||
}
|
||||
const token = $("#gestureDebugSelect")?.value;
|
||||
if (!token || !state.engine) {
|
||||
notify("手势调试能力尚未就绪");
|
||||
return;
|
||||
}
|
||||
const tokenName = TOKEN_NAMES[token] || token;
|
||||
$("#gestureDetected").textContent = `模拟 · ${tokenName}`;
|
||||
if (token === "OPEN_PALM") {
|
||||
state.engine.setArmed(true);
|
||||
$("#gestureExpected").textContent = "激活控制";
|
||||
notify("已发送模拟张掌手势");
|
||||
return;
|
||||
}
|
||||
if (token === "CLOSED_FIST") {
|
||||
state.engine.stopMotion("管理员模拟握拳 · 已停止当前动作");
|
||||
$("#gestureExpected").textContent = "停止当前动作";
|
||||
notify("已发送模拟握拳手势");
|
||||
return;
|
||||
}
|
||||
const action = mappingForToken(token);
|
||||
if (!action || !ACTIONS[action]) {
|
||||
$("#gestureExpected").textContent = "未绑定动作";
|
||||
notify("该模拟手势尚未绑定动作");
|
||||
return;
|
||||
}
|
||||
state.lastRecognizedToken = token;
|
||||
$("#gestureExpected").textContent = ACTIONS[action].label;
|
||||
$("#gestureSafetyText").textContent = `管理员模拟手势:${tokenName}`;
|
||||
executeGestureAction(action);
|
||||
notify(`已发送模拟手势:${tokenName}`);
|
||||
}
|
||||
|
||||
function updateCustomizeToken() {
|
||||
const token = state.lastRecognizedToken;
|
||||
$("#gestureCustomizeToken").textContent = token ? TOKEN_NAMES[token] || token : "请先摆出手势";
|
||||
@@ -635,26 +800,42 @@ function initModeRail() {
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
$("#gestureStartButton").disabled = true;
|
||||
$("#gestureToggleButton")?.addEventListener("click", openDrawer);
|
||||
$("#gestureCloseButton")?.addEventListener("click", closeDrawer);
|
||||
$("#gestureBackdrop")?.addEventListener("click", closeDrawer);
|
||||
$("#gestureStartButton")?.addEventListener("click", toggleCamera);
|
||||
$("#gestureArmButton")?.addEventListener("click", () => state.engine?.setArmed(!state.engine.armed));
|
||||
$("#gestureArmButton")?.addEventListener("click", toggleGestureDebug);
|
||||
$("#gestureStopButton")?.addEventListener("click", () => state.engine?.setArmed(false));
|
||||
$("#gestureDebugSend")?.addEventListener("click", sendSimulatedGesture);
|
||||
$("#gestureCustomizeButton")?.addEventListener("click", toggleCustomize);
|
||||
$("#gestureBindButton")?.addEventListener("click", bindCurrentGesture);
|
||||
$("#gestureResetMapping")?.addEventListener("click", resetMapping);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && $("#gestureDrawer")?.classList.contains("open")) closeDrawer();
|
||||
if (event.key !== "Escape") return;
|
||||
if (!$("#gestureDebugPanel")?.hidden) {
|
||||
setGestureDebugOpen(false);
|
||||
return;
|
||||
}
|
||||
if ($("#gestureDrawer")?.classList.contains("open")) closeDrawer();
|
||||
});
|
||||
window.addEventListener("cognitive-agent:demo-role-change", (event) => {
|
||||
if (event.detail?.role !== "admin") setGestureDebugOpen(false);
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => { if (document.hidden && state.stream) stopCamera(); });
|
||||
window.addEventListener("beforeunload", stopCamera);
|
||||
navigator.mediaDevices?.addEventListener?.("devicechange", handleCameraDeviceChange);
|
||||
initModeRail();
|
||||
let runtimeError = null;
|
||||
try {
|
||||
await loadGestureRuntime();
|
||||
$("#gestureSafetyText").textContent = state.runtime.available ? "能力就绪,开启摄像头开始识别" : "内置手势模型不可用";
|
||||
state.runtimeReady = Boolean(state.runtime.available && state.runtime.model_available);
|
||||
if (!state.runtimeReady) runtimeError = new Error("内置手势模型不可用");
|
||||
} catch (error) {
|
||||
$("#gestureSafetyText").textContent = error.message;
|
||||
$("#gestureStartButton").disabled = true;
|
||||
runtimeError = error;
|
||||
state.runtimeReady = false;
|
||||
}
|
||||
await refreshCameraAvailability({ announce: !runtimeError });
|
||||
if (runtimeError) $("#gestureSafetyText").textContent = runtimeError.message;
|
||||
syncCameraButtonAvailability();
|
||||
});
|
||||
|
||||
+56
-24
@@ -8,16 +8,18 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icons/app-icon-192.png" />
|
||||
<title>智舱随行 · 一句话与手势控制</title>
|
||||
<title>智舱随行 · 整车智能控制台</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=android-pad-3" />
|
||||
<link rel="stylesheet" href="/console-v2.css?v=desktop-refactor-56" />
|
||||
<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-13" defer></script>
|
||||
<script type="module" src="/gesture-control.js?v=android-pad-1"></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="/pwa.js?v=android-pad-1" defer></script>
|
||||
<script src="/console-v2.js?v=desktop-refactor-16" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -147,7 +149,7 @@
|
||||
<p class="provider-note" id="autoFlashNote">正在检查自动烧录配置</p>
|
||||
</div>
|
||||
|
||||
<button class="primary-action" id="runButton" type="submit">
|
||||
<button class="primary-action" id="runButton" type="submit" aria-label="执行车舱控制指令">
|
||||
<i data-lucide="play"></i>
|
||||
<span>生成并执行</span>
|
||||
</button>
|
||||
@@ -229,7 +231,6 @@
|
||||
<section class="cabin-band" aria-labelledby="cabinStateTitle">
|
||||
<div class="section-title-row cabin-heading">
|
||||
<div>
|
||||
<span class="section-kicker">JOINT DEVICE STATE</span>
|
||||
<h3 id="cabinStateTitle">联合设备状态</h3>
|
||||
</div>
|
||||
<span class="simulation-label" id="feedbackModeLabel">MATRIX-BACKED · SIMULATED FEEDBACK</span>
|
||||
@@ -252,6 +253,14 @@
|
||||
<span>副驾</span><strong id="passengerTemperature">24<sup>℃</sup></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="climate-display climate-direction-display" aria-label="双区空调风向">
|
||||
<div class="zone-temperature">
|
||||
<span>主驾风向</span><strong id="driverAirDirection">-</strong>
|
||||
</div>
|
||||
<div class="zone-temperature passenger-temperature">
|
||||
<span>副驾风向</span><strong id="passengerAirDirection">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="domain-readouts">
|
||||
<div><dt>风量</dt><dd id="fanLevel">0 档</dd></div>
|
||||
<div><dt>循环</dt><dd id="cycleMode">外循环</dd></div>
|
||||
@@ -273,20 +282,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<dl class="domain-readouts">
|
||||
<div><dt>透光率</dt><dd id="transmittanceValue">100%</dd></div>
|
||||
<div><dt>遮阳帘</dt><dd id="shadePositionValue">0%</dd></div>
|
||||
<div><dt>自动调光</dt><dd id="automaticDimming">关闭</dd></div>
|
||||
<div><dt>电机反馈</dt><dd id="roofMotorState">已停止</dd></div>
|
||||
<div><dt>调光</dt><dd id="automaticDimming">关闭</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="domain-panel ambient-panel" data-domain-panel="ambient_light">
|
||||
<header>
|
||||
<span class="domain-icon"><i data-lucide="lamp-ceiling"></i></span>
|
||||
<div><strong>氛围灯</strong><small>AMBIENT · RGB 16.8M</small></div>
|
||||
<div><strong>阅读灯</strong><small>AMBIENT · RGB 16.8M</small></div>
|
||||
<span class="device-state" id="ambientPower">OFF</span>
|
||||
</header>
|
||||
<div class="ambient-visual" id="ambientVisual" aria-label="副驾手套箱上方九段氛围灯排灯">
|
||||
<div class="ambient-visual" id="ambientVisual" aria-label="副驾阅读灯状态">
|
||||
<div class="ambient-dashboard-sketch" aria-hidden="true">
|
||||
<span class="ambient-dashboard-top"></span>
|
||||
<span class="ambient-center-screen"></span>
|
||||
@@ -299,11 +306,36 @@
|
||||
</div>
|
||||
<small class="ambient-location-label">副驾 · 手套箱上方排灯</small>
|
||||
</div>
|
||||
<dl class="domain-readouts">
|
||||
<div><dt>颜色</dt><dd><i class="color-swatch" id="ambientSwatch"></i><span id="ambientColor">#74D9FF</span></dd></div>
|
||||
<div><dt>亮度</dt><dd id="ambientBrightness">0%</dd></div>
|
||||
<dl class="domain-readouts single-readout">
|
||||
<div><dt>模式</dt><dd id="ambientMode">关闭</dd></div>
|
||||
<div><dt>控制精度</dt><dd>24-bit RGB</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="domain-panel window-panel" data-domain-panel="window">
|
||||
<header>
|
||||
<span class="domain-icon"><i data-lucide="gallery-vertical-end"></i></span>
|
||||
<div><strong>车窗</strong><small>WINDOW · OPENING</small></div>
|
||||
<span class="device-state" id="windowState">READY</span>
|
||||
</header>
|
||||
<dl class="domain-readouts">
|
||||
<div><dt>左前窗</dt><dd id="frontLeftWindow">0%</dd></div>
|
||||
<div><dt>右前窗</dt><dd id="frontRightWindow">0%</dd></div>
|
||||
<div><dt>左后窗</dt><dd id="rearLeftWindow">0%</dd></div>
|
||||
<div><dt>右后窗</dt><dd id="rearRightWindow">0%</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="domain-panel lighting-panel" data-domain-panel="front_light">
|
||||
<header>
|
||||
<span class="domain-icon"><i data-lucide="car-front"></i></span>
|
||||
<div><strong>前大灯</strong><small>FRONT LIGHT · SWITCH</small></div>
|
||||
<span class="device-state" id="frontLightState">OFF</span>
|
||||
</header>
|
||||
<dl class="domain-readouts">
|
||||
<div><dt>近光灯</dt><dd id="lowBeamState">关闭</dd></div>
|
||||
<div><dt>远光灯</dt><dd id="highBeamState">关闭</dd></div>
|
||||
<div><dt>雾灯</dt><dd id="fogLightState">关闭</dd></div>
|
||||
<div><dt>转向灯</dt><dd id="turnSignalState">关闭</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
@@ -312,26 +344,19 @@
|
||||
<section class="twin-band" aria-labelledby="twinTitle">
|
||||
<div class="twin-heading">
|
||||
<div>
|
||||
<span class="section-kicker">FULL VEHICLE DIGITAL TWIN</span>
|
||||
<h3 id="twinTitle">整车孪生界面</h3>
|
||||
<div class="preset-indicator" id="presetIndicator" hidden>
|
||||
<i data-lucide="bookmark-check"></i><span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="twin-toolbar">
|
||||
<span class="twin-focus-badge"><i data-lucide="scan"></i>透明车体 · 实体设备</span>
|
||||
<button class="icon-button twin-reset" id="twinReset" type="button" title="复位 3D 视角" aria-label="复位 3D 视角">
|
||||
<i data-lucide="rotate-3d"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="twin-stage" id="seatTwin" data-selected-seat="passenger">
|
||||
<canvas id="seatTwinCanvas" data-vehicle-model="byd-tang-2024-reference" data-seat-layout="five-seat" data-active-seat="front-right" aria-label="具有前后遮挡明暗层次的半透明整车外壳、始终清晰显示的动态副驾、全景天幕、仪表台空调出风口与副驾手套箱上方氛围灯三维状态"></canvas>
|
||||
<div class="vehicle-system-hud" aria-label="整车孪生系统状态">
|
||||
<span data-twin-system="roof"><i data-lucide="panels-top-left"></i><small>全景天幕</small><b id="twinRoofState">透光 100%</b></span>
|
||||
<span data-twin-system="climate"><i data-lucide="wind"></i><small>仪表台 HVAC</small><b id="twinClimateState">关闭</b></span>
|
||||
<span data-twin-system="ambient"><i data-lucide="lamp-ceiling"></i><small>手套箱氛围灯</small><b id="twinAmbientState">关闭</b></span>
|
||||
</div>
|
||||
<canvas id="seatTwinCanvas" data-vehicle-model="byd-tang-2024-reference" data-seat-layout="five-seat" data-active-seat="front-right" aria-label="通过模型动画展示动态副驾、空调气流、智能天幕、阅读灯、四门车窗与前大灯状态的整车数字孪生"></canvas>
|
||||
<div class="twin-seat-label passenger"><i></i><span>副驾动态座椅</span><small>ACTIVE · FRONT RIGHT</small></div>
|
||||
<div class="twin-callout-layer" aria-hidden="true">
|
||||
<div class="twin-callout" data-callout="longitudinal"><span>水平位置</span><b>50%</b><small>导轨</small></div>
|
||||
@@ -506,12 +531,19 @@
|
||||
|
||||
<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" disabled><i data-lucide="hand"></i>激活控制</button>
|
||||
<button id="gestureArmButton" type="button" aria-controls="gestureDebugPanel" aria-expanded="false"><i data-lucide="bug-play"></i>手势调试</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>
|
||||
<div class="gesture-debug-controls">
|
||||
<select id="gestureDebugSelect" aria-label="选择模拟手势"></select>
|
||||
<button id="gestureDebugSend" type="button"><i data-lucide="send"></i>发送模拟手势</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="gesture-guide-card">
|
||||
<div class="gesture-card-heading"><div><span>动作速查</span><small>张掌激活 · 握拳只停止</small></div><button id="gestureCustomizeButton" type="button"><i data-lucide="sliders-horizontal"></i>自定义</button></div>
|
||||
<div class="gesture-card-heading"><div><span>手势速查</span><small>张掌激活 · 握拳只停止</small></div><button id="gestureCustomizeButton" type="button"><i data-lucide="sliders-horizontal"></i>自定义</button></div>
|
||||
<div class="gesture-map-grid" id="gestureMapGrid"></div>
|
||||
<div class="gesture-customize" id="gestureCustomizePanel" hidden>
|
||||
<div><span>识别到</span><strong id="gestureCustomizeToken">请先摆出手势</strong></div>
|
||||
|
||||
Reference in New Issue
Block a user