feat(n7web): refine spatial UI and NFC-gated extension routing

This commit is contained in:
Miayui
2026-09-16 16:14:10 +08:00
parent 0e4328f548
commit 75ce391312
16 changed files with 925 additions and 106 deletions
+22
View File
@@ -722,6 +722,19 @@
return "binary";
}
function focusLatestDispatchedControls(container) {
requestAnimationFrame(() => {
const transcript = $("#assistantMessages");
const activeCard = container?.matches?.(".assistant-dispatched-controls")
? container
: $(".assistant-dispatched-controls", container);
if (!transcript || !activeCard) return;
transcript.querySelectorAll(".assistant-dispatched-controls").forEach((card) => {
card.open = card === activeCard;
});
});
}
function appendDispatchedControls(message, result = {}) {
if (!message?.body || !result || typeof result !== "object") return;
const previous = message.dispatchedControlResult || {};
@@ -766,6 +779,7 @@
if (vueRenderer(host, viewCommands)) {
host.dataset.commandSignature = signature;
message.article?.classList.add("assistant-message-with-dispatched-controls");
focusLatestDispatchedControls(host);
followLatest(true);
return;
}
@@ -777,6 +791,13 @@
card = document.createElement("details");
card.className = "assistant-dispatched-controls";
card.open = true;
card.addEventListener("toggle", () => {
if (!card.open) return;
const transcript = $("#assistantMessages");
transcript?.querySelectorAll(".assistant-dispatched-controls[open]").forEach((otherCard) => {
if (otherCard !== card) otherCard.open = false;
});
});
const summary = document.createElement("summary");
const headingIcon = document.createElement("span");
headingIcon.className = "assistant-dispatched-controls-heading-icon";
@@ -828,6 +849,7 @@
list.append(row);
});
message.article?.classList.add("assistant-message-with-dispatched-controls");
focusLatestDispatchedControls(card);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
followLatest(true);
}
+82 -18
View File
@@ -568,7 +568,11 @@
const verificationStatus = String(telemetry.verification_status || "").toLowerCase();
const failureMessage = String(execution?.message || "").trim();
const aborted = abortedStates.has(executionStatus);
const explicitlyBlocked = executionStatus === "blocked"
|| verificationStatus === "blocked"
|| Boolean(telemetry.authorization_reason);
const executionFailed = executionStatus === "failed"
|| explicitlyBlocked
|| verificationStatus === "failed_or_blocked";
const missingWindowFeedback = /0x388/i.test(failureMessage) && (
/no fresh valid/i.test(failureMessage)
@@ -579,7 +583,8 @@
|| /未收到.*(?:有效|新鲜).*遮阳帘.*位置/.test(failureMessage)
);
const safetyBlocked = executionFailed && !submitted && (
missingWindowFeedback
explicitlyBlocked
|| missingWindowFeedback
|| missingShadeFeedback
|| /refusing (?:blind )?motion/i.test(failureMessage)
|| /拒绝(?:启动机构|盲动|执行)/.test(failureMessage)
@@ -1100,9 +1105,9 @@
sidebarToggle.setAttribute("aria-expanded", "false");
sidebarToggle.title = "展开左侧控制台";
const footer = make("footer", "v2-sidebar-footer", `
const footer = make("footer", "v2-sidebar-footer v2-control-link-card", `
<span class="v2-live-dot"></span>
<div><strong>智舱控制链路</strong><small id="v2ConnectionText">正在连接服务</small></div>
<div><strong>智舱控制链路</strong><small id="v2ConnectionText" aria-live="polite">正在连接服务</small></div>
<button type="button" id="v2LogoutButton" title="退出登录" aria-label="退出登录"><i data-lucide="log-out"></i></button>`);
commandPanel.append(sidebarToggle, footer);
@@ -1675,6 +1680,11 @@
if (!list) return;
const filter = $("#v2LogFilter")?.value || "all";
const items = displaySync.logs.filter((item) => filter === "all" || item.category === filter);
const count = $("#v2SyncLogCount");
if (count) {
count.textContent = String(items.length);
count.title = `${items.length} 条记录`;
}
const icons = { task: "route", sync: "refresh-cw", extension: "usb", device: "activity" };
list.innerHTML = items.length ? items.map((item) => `
<li data-log-status="${escapeHtml(item.status)}">
@@ -1685,6 +1695,21 @@
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function setSyncLogCollapsed(collapsed) {
const panel = $(".v2-sync-log");
const list = $("#v2SyncLogList");
const toggle = $("#v2SyncLogToggle");
if (!panel || !list || !toggle) return;
const nextCollapsed = Boolean(collapsed);
const label = nextCollapsed ? "展开运行日志与同步记录" : "折叠运行日志与同步记录";
panel.dataset.collapsed = String(nextCollapsed);
list.hidden = nextCollapsed;
toggle.setAttribute("aria-expanded", String(!nextCollapsed));
toggle.setAttribute("aria-label", label);
toggle.title = label;
requestAnimationFrame(syncSidebarLogBaseline);
}
function panelVisible(key) {
const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel");
if (panel) return !panel.classList.contains("is-collapsed");
@@ -2439,9 +2464,15 @@
const sidebarFooter = $(".v2-sidebar-footer", sidebar);
const syncSidebar = make("section", "v2-sync-sidebar", `
<div class="v2-sync-sidebar-overview"></div>
<section class="v2-sync-log" aria-labelledby="v2SyncLogTitle">
<header><div><h3 id="v2SyncLogTitle">运行日志 / 同步记录</h3></div><select id="v2LogFilter" aria-label="筛选同步日志"><option value="all">全部</option><option value="task">任务</option><option value="sync">同步</option><option value="extension">外设</option><option value="device">设备</option></select></header>
<ol id="v2SyncLogList" aria-live="polite"></ol>
<section class="v2-sync-log" aria-labelledby="v2SyncLogTitle" data-collapsed="true">
<header>
<div><h3 id="v2SyncLogTitle">运行日志 / 同步记录</h3></div>
<div class="v2-sync-log-actions">
<select id="v2LogFilter" aria-label="筛选同步日志"><option value="all">全部</option><option value="task">任务</option><option value="sync">同步</option><option value="extension">外设</option><option value="device">设备</option></select>
<button type="button" class="v2-sync-log-toggle" id="v2SyncLogToggle" aria-controls="v2SyncLogList" aria-expanded="false" aria-label="展开运行日志与同步记录" title="展开运行日志与同步记录"><span id="v2SyncLogCount">0</span><i data-lucide="chevron-down"></i></button>
</div>
</header>
<ol id="v2SyncLogList" aria-live="polite" hidden></ol>
</section>`);
if (sidebar && sidebarFooter) {
sidebar.insertBefore(syncSidebar, sidebarFooter);
@@ -2574,6 +2605,10 @@
if (event.target.matches("select[data-device-control]")) storeDeviceControlDraft(event.target);
});
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
$("#v2SyncLogToggle")?.addEventListener("click", () => {
setSyncLogCollapsed($(".v2-sync-log")?.dataset.collapsed !== "true");
});
setSyncLogCollapsed(true);
const beginPendingControlRequest = (request = {}) => {
beginCommandTiming(request);
resetValidatedCommandContext();
@@ -2879,18 +2914,38 @@
const connectionText = $("#v2ConnectionText");
if (connectionText) {
const status = connectionState?.status || "connecting";
const labels = {
connecting: "正在连接服务",
online: "服务已连接",
degraded: "连接不稳定",
offline: "服务已断开",
};
connectionText.textContent = labels[status] || labels.connecting;
connectionText.title = connectionState?.lastError || connectionText.textContent;
$(".v2-sidebar-footer")?.classList.toggle(
"offline",
status === "offline" || status === "degraded",
);
const selectedTransport = $('.vehicle-transport-options [data-control-transport][aria-pressed="true"]')
|| $('.vehicle-transport-options [data-control-transport].active');
const transport = selectedTransport?.dataset.controlTransport || "";
const transportOnline = selectedTransport?.dataset.connectionState === "online";
const transportLabel = transport === "wireless_mqtt" ? "无线 CAN" : "有线 CAN";
const footer = connectionText.closest(".v2-control-link-card");
let presentationState = status;
let label = "正在连接服务";
if (status === "online" && transport) {
presentationState = transportOnline ? "online" : "offline";
label = transport === "wireless_mqtt"
? `${transportLabel} · MQTT ${transportOnline ? "已连接" : "未连接"}`
: `${transportLabel} · 信号链路${transportOnline ? "正常" : "断开"}`;
} else if (status === "online") {
presentationState = "online";
label = "服务已连接";
} else if (status === "degraded") {
label = transport ? `${transportLabel} · 主服务连接不稳定` : "主服务连接不稳定";
} else if (status === "offline") {
label = transport ? `${transportLabel} · 主服务已断开` : "主服务已断开";
}
connectionText.textContent = label;
connectionText.title = connectionState?.lastError
|| (!transportOnline ? selectedTransport?.title : "")
|| label;
if (footer) {
footer.dataset.transport = transport || "service";
footer.dataset.state = presentationState;
footer.classList.toggle("offline", presentationState !== "online");
}
}
keepRunButtonAvailable();
}
@@ -2927,6 +2982,7 @@
syncRuntimePresentation(event.detail);
});
window.addEventListener("cognitive-agent:vehicle-feedback-state", () => {
syncRuntimePresentation();
renderSyncedDevices();
if (displaySync.activeDevice) {
presentTwinDevice(displaySync.activeDevice, {
@@ -2935,6 +2991,14 @@
});
}
});
const transportOptions = $(".vehicle-transport-options");
if (transportOptions) {
new MutationObserver(() => syncRuntimePresentation()).observe(transportOptions, {
attributes: true,
attributeFilter: ["aria-pressed", "class", "data-connection-state", "title"],
subtree: true,
});
}
const observer = new MutationObserver(() => keepRunButtonAvailable());
[$("#hardwareMode"), $("#simulationMode")]
.filter(Boolean)
+7 -2
View File
@@ -1224,7 +1224,9 @@
}
showGscuResult(result);
showToast(
action === "learn" ? `IR ${slot} 学习完成,槽位回读已确认。` : `${action} 指令处理完成。`,
action === "learn"
? `IR ${slot} 学习完成并已加入自然语言解析;实际下发仍需 NFC 授权。`
: `${action} 指令处理完成。`,
"success",
);
} catch (error) {
@@ -1275,7 +1277,10 @@
$("[data-gscu-action-names]", card).value,
"tcp",
).then((value) => {
showToast(`IR ${value.slot} 的控制对象与执行动作已保存。`, "success");
showToast(
`IR ${value.slot} 已保存并立即加入自然语言解析;实际下发仍需 NFC 授权。`,
"success",
);
}).catch((error) => {
showToast(error.message, "error");
}).finally(() => { save.disabled = false; });
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite";
const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -44,6 +44,7 @@ body.console-v2 :is(
.v2-sync-transport-field,
.v2-sync-execution-field,
.v2-can-command-panel,
.v2-control-link-card,
.v2-nfc-inline,
.v2-extension-gateway,
.v2-extension-synced-device,
@@ -132,6 +133,119 @@ body.console-v2 .v2-sync-log h3 {
white-space: nowrap;
}
/* Sidebar disclosure, connection status and control-command readability use
* the same semantic tokens in both day and night themes. */
body.console-v2 .v2-sync-log[data-collapsed="true"] {
min-height: 0;
align-self: start;
grid-template-rows: auto;
flex: 0 0 auto;
}
body.console-v2 .v2-sync-log[data-collapsed="true"] > header {
border-bottom-color: transparent;
}
body.console-v2 .v2-sync-log[data-collapsed="true"] #v2LogFilter {
display: none;
}
body.console-v2 .v2-sync-log #v2SyncLogList[hidden] {
display: none !important;
}
body.console-v2 .v2-sync-log > header > div:first-child {
min-width: 0;
}
body.console-v2 .v2-sync-log .v2-sync-log-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
body.console-v2 .v2-sync-log .v2-sync-log-toggle {
min-width: 46px;
min-height: var(--n7-control-height-sm);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 7px;
color: var(--muted-foreground);
background: var(--secondary);
border: 1px solid var(--input);
border-radius: var(--n7-radius-control);
cursor: pointer;
}
body.console-v2 .v2-sync-log .v2-sync-log-toggle:is(:hover, :focus-visible) {
color: var(--accent-foreground);
background: var(--accent);
border-color: var(--ring);
outline: none;
}
body.console-v2 .v2-sync-log .v2-sync-log-toggle svg {
width: 15px;
height: 15px;
transition: transform 160ms ease;
}
body.console-v2 .v2-sync-log .v2-sync-log-toggle[aria-expanded="true"] svg {
transform: rotate(180deg);
}
body.console-v2.v2-passive-display.v2-interactive-sync .v2-control-link-card {
min-height: 56px;
grid-template-columns: 10px minmax(0, 1fr);
gap: 10px;
margin: 0 !important;
padding: 10px 11px !important;
color: var(--card-foreground);
background: var(--glass-card) !important;
border: 1px solid var(--glass-line) !important;
border-radius: var(--n7-radius-card) !important;
box-shadow: var(--n7-shadow-card) !important;
-webkit-backdrop-filter: blur(16px) saturate(1.2);
backdrop-filter: blur(16px) saturate(1.2);
}
body.console-v2 .v2-control-link-card strong {
color: var(--card-foreground) !important;
}
body.console-v2 .v2-control-link-card small {
color: var(--muted-foreground) !important;
}
body.console-v2 .v2-control-link-card[data-state="online"] .v2-live-dot {
background: var(--status-online);
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-online) 18%, transparent);
}
body.console-v2 .v2-control-link-card[data-state="connecting"] .v2-live-dot {
background: var(--status-info);
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-info) 18%, transparent);
}
body.console-v2 .v2-control-link-card[data-state="degraded"] .v2-live-dot {
background: var(--status-warning);
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-warning) 18%, transparent);
}
body.console-v2 .v2-control-link-card[data-state="offline"] .v2-live-dot {
background: var(--status-offline);
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-offline) 18%, transparent);
}
body.console-v2.v2-passive-display.v2-interactive-sync
.v2-can-command-list li[data-can-channel]
.v2-can-command-actual code {
color: var(--card-foreground) !important;
}
body.console-v2 :is(
.mode-gallery-header h2,
.mode-editor-header h2,
+5
View File
@@ -120,6 +120,11 @@ import { Fragment, createApp, h, nextTick, reactive } from "vue";
open: state.open,
onToggle(event) {
state.open = event.currentTarget.open;
if (!state.open) return;
const transcript = document.querySelector("#assistantMessages");
transcript?.querySelectorAll(".assistant-dispatched-controls[open]").forEach((card) => {
if (card !== event.currentTarget) card.open = false;
});
},
}, [
h("summary", null, [