From 772ab685d68f7e7b405d852d4fdcf40f00463a45 Mon Sep 17 00:00:00 2001 From: vee Date: Wed, 19 Aug 2026 08:36:38 +0800 Subject: [PATCH] feat(web-console): refine intelligent cockpit sync interface --- apps/web-console/console-v2.css | 987 +++++++++++++++++- apps/web-console/console-v2.js | 402 +++++-- apps/web-console/index.html | 8 +- apps/web-console/service-worker.js | 10 +- tests/e2e/test_seat_web_api.py | 18 +- .../test_requested_frontend_enhancements.py | 110 ++ 6 files changed, 1420 insertions(+), 115 deletions(-) diff --git a/apps/web-console/console-v2.css b/apps/web-console/console-v2.css index f419a0c..caad520 100644 --- a/apps/web-console/console-v2.css +++ b/apps/web-console/console-v2.css @@ -3178,46 +3178,140 @@ } .console-v2 .v2-twin-hotspots button { + --v2-hotspot-label-x: 0px; + --v2-hotspot-label-y: -52px; + --v2-hotspot-line-length: 52px; + --v2-hotspot-line-angle: -90deg; + --v2-hotspot-label-scale: 1; + width: 14px; + height: 14px; position: absolute; - display: flex; - align-items: center; - gap: 4px; - padding: 5px 7px; - color: #164846; - background: rgba(242, 252, 250, 0.92); - border: 1px solid rgba(12, 119, 111, 0.2); - border-radius: 999px; - box-shadow: 0 7px 18px rgba(22, 82, 78, 0.13); - font-size: 8px; - font-weight: 850; + display: block; + padding: 0; + color: #ffffff; + background: transparent; + border: 0; + border-radius: 50%; + box-shadow: none; cursor: pointer; pointer-events: auto; transform: translate(-50%, -50%); - transition: transform 150ms ease, background 150ms ease, color 150ms ease; + overflow: visible; +} + +.console-v2 .v2-twin-hotspots button::before { + content: ""; + width: var(--v2-hotspot-line-length); + height: 1.5px; + position: absolute; + top: 50%; + left: 50%; + z-index: 0; + background: linear-gradient(90deg, #12a69b, rgba(18, 166, 155, 0.55)); + box-shadow: 0 0 7px rgba(18, 166, 155, 0.24); + transform: rotate(var(--v2-hotspot-line-angle)); + transform-origin: 0 50%; + pointer-events: auto; + transition: height 120ms ease, background 120ms ease, box-shadow 120ms ease; +} + +.console-v2 .v2-twin-hotspots button::after { + content: ""; + width: 8px; + height: 8px; + position: absolute; + top: 50%; + left: 50%; + z-index: 3; + background: #11b5aa; + border: 2px solid rgba(255, 255, 255, 0.94); + border-radius: 50%; + box-shadow: 0 0 0 3px rgba(17, 181, 170, 0.17), 0 2px 8px rgba(17, 105, 99, 0.28); + transform: translate(-50%, -50%); + transition: transform 120ms ease, background 120ms ease, box-shadow 120ms ease; +} + +.console-v2 .v2-twin-hotspot-label { + min-width: 78px; + min-height: 34px; + position: absolute; + top: calc(50% + var(--v2-hotspot-label-y)); + left: calc(50% + var(--v2-hotspot-label-x)); + z-index: 2; + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + grid-template-rows: auto auto; + align-items: center; + gap: 1px 6px; + padding: 6px 8px; + color: #f4fffd; + background: linear-gradient(145deg, rgba(19, 66, 72, 0.96), rgba(12, 48, 54, 0.94)); + border: 1px solid rgba(151, 232, 222, 0.24); + border-radius: 9px; + box-shadow: 0 8px 20px rgba(21, 70, 69, 0.2); + text-align: left; + white-space: nowrap; + transform: translate(-50%, -50%) scale(var(--v2-hotspot-label-scale)); + transform-origin: center; + backdrop-filter: blur(8px); + transition: transform 120ms ease, background 120ms ease, border-color 120ms ease, box-shadow 120ms ease; +} + +.console-v2 .v2-twin-hotspot-label svg { + width: 16px; + height: 16px; + grid-column: 1; + grid-row: 1 / 3; + color: #77e1d6; +} + +.console-v2 .v2-twin-hotspot-label b { + grid-column: 2; + grid-row: 1; + font-size: 9px; + line-height: 1.15; +} + +.console-v2 .v2-twin-hotspot-label small { + grid-column: 2; + grid-row: 2; + color: rgba(221, 246, 242, 0.7); + font-size: 7px; + font-weight: 700; + line-height: 1.1; } .console-v2 .v2-twin-hotspots button:hover, .console-v2 .v2-twin-hotspots button:focus-visible, .console-v2 .v2-twin-hotspots button.active { z-index: 2; - color: #fff; - background: #0b7f78; outline: none; - transform: translate(-50%, -50%) scale(1.08); + --v2-hotspot-label-scale: 1.05; } -.console-v2 .v2-twin-hotspots button svg { - width: 11px; +.console-v2 .v2-twin-hotspots button:hover::before, +.console-v2 .v2-twin-hotspots button:focus-visible::before, +.console-v2 .v2-twin-hotspots button.active::before { + height: 2px; + background: #0d968d; + box-shadow: 0 0 9px rgba(13, 150, 141, 0.42); } -.console-v2 .v2-twin-hotspots [data-twin-device="windows"] { top: 48%; left: 8%; } -.console-v2 .v2-twin-hotspots [data-twin-device="shade"] { top: 16%; left: 45%; } -.console-v2 .v2-twin-hotspots [data-twin-device="driver"] { top: 46%; left: 63%; } -.console-v2 .v2-twin-hotspots [data-twin-device="passenger"] { top: 58%; left: 44%; } -.console-v2 .v2-twin-hotspots [data-twin-device="reading"] { top: 30%; left: 34%; } -.console-v2 .v2-twin-hotspots [data-twin-device="ambient"] { top: 78%; left: 47%; } -.console-v2 .v2-twin-hotspots [data-twin-device="headlight"] { top: 62%; left: 88%; } -.console-v2 .v2-twin-hotspots [data-twin-device="climate"] { top: 80%; left: 12%; } +.console-v2 .v2-twin-hotspots button:hover::after, +.console-v2 .v2-twin-hotspots button:focus-visible::after, +.console-v2 .v2-twin-hotspots button.active::after { + background: #087a73; + box-shadow: 0 0 0 5px rgba(8, 122, 115, 0.18), 0 2px 9px rgba(8, 93, 88, 0.34); + transform: translate(-50%, -50%) scale(1.18); +} + +.console-v2 .v2-twin-hotspots button:hover .v2-twin-hotspot-label, +.console-v2 .v2-twin-hotspots button:focus-visible .v2-twin-hotspot-label, +.console-v2 .v2-twin-hotspots button.active .v2-twin-hotspot-label { + background: linear-gradient(145deg, #0c8b83, #08716b); + border-color: rgba(196, 255, 246, 0.48); + box-shadow: 0 11px 24px rgba(8, 103, 96, 0.28); +} .console-v2 .v2-twin-device-popover { width: min(360px, calc(100% - 34px)); @@ -3241,9 +3335,21 @@ display: none; } +.console-v2 .v2-twin-device-popover[data-pinned="false"] { + pointer-events: none; +} + +.console-v2 .v2-twin-device-popover[data-pinned="false"] header { + grid-template-columns: 30px minmax(0, 1fr) auto; +} + +.console-v2 .v2-twin-device-popover[data-pinned="false"] .v2-twin-popover-close { + display: none; +} + .console-v2 .v2-twin-device-popover header { display: grid; - grid-template-columns: 30px minmax(0, 1fr) auto; + grid-template-columns: 30px minmax(0, 1fr) auto 28px; align-items: center; gap: 8px; } @@ -3282,6 +3388,34 @@ font-size: 8px; } +.console-v2 .v2-twin-popover-close { + width: 28px; + height: 28px; + display: inline-grid; + place-items: center; + padding: 0; + color: #557471; + background: #f1f7f6; + border: 1px solid rgba(16, 111, 104, 0.12); + border-radius: 9px; + cursor: pointer; + transition: color 150ms ease, background 150ms ease, border-color 150ms ease, transform 150ms ease; +} + +.console-v2 .v2-twin-popover-close:hover, +.console-v2 .v2-twin-popover-close:focus-visible { + color: #ffffff; + background: #118078; + border-color: #118078; + outline: none; + transform: scale(1.04); +} + +.console-v2 .v2-twin-popover-close svg { + width: 14px; + height: 14px; +} + .console-v2 .v2-twin-device-popover dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -6348,15 +6482,15 @@ body.device-power-drawer-open { display: none !important; } -/* Hotspots follow the actual rendered vehicle: rear at left, front at right. */ -.console-v2 .v2-twin-hotspots [data-twin-device="windows"] { top: 50%; left: 42%; } -.console-v2 .v2-twin-hotspots [data-twin-device="shade"] { top: 25%; left: 53%; } -.console-v2 .v2-twin-hotspots [data-twin-device="driver"] { top: 45%; left: 62%; } -.console-v2 .v2-twin-hotspots [data-twin-device="passenger"] { top: 57%; left: 52%; } -.console-v2 .v2-twin-hotspots [data-twin-device="reading"] { top: 36%; left: 55%; } -.console-v2 .v2-twin-hotspots [data-twin-device="ambient"] { top: 62%; left: 66%; } -.console-v2 .v2-twin-hotspots [data-twin-device="headlight"] { top: 66%; left: 86%; } -.console-v2 .v2-twin-hotspots [data-twin-device="climate"] { top: 48%; left: 73%; } +/* Anchors follow the rendered vehicle; labels fan out along readable leader lines. */ +.console-v2 .v2-twin-hotspots [data-twin-device="windows"] { top: 50%; left: 42%; --v2-hotspot-label-x: -115px; --v2-hotspot-label-y: 0px; --v2-hotspot-line-length: 115px; --v2-hotspot-line-angle: 180deg; } +.console-v2 .v2-twin-hotspots [data-twin-device="shade"] { top: 25%; left: 53%; --v2-hotspot-label-x: 0px; --v2-hotspot-label-y: -65px; --v2-hotspot-line-length: 65px; --v2-hotspot-line-angle: -90deg; } +.console-v2 .v2-twin-hotspots [data-twin-device="driver"] { top: 45%; left: 62%; --v2-hotspot-label-x: 110px; --v2-hotspot-label-y: -70px; --v2-hotspot-line-length: 130px; --v2-hotspot-line-angle: -32deg; } +.console-v2 .v2-twin-hotspots [data-twin-device="passenger"] { top: 57%; left: 52%; --v2-hotspot-label-x: -105px; --v2-hotspot-label-y: 55px; --v2-hotspot-line-length: 119px; --v2-hotspot-line-angle: 152deg; } +.console-v2 .v2-twin-hotspots [data-twin-device="reading"] { top: 36%; left: 55%; --v2-hotspot-label-x: -120px; --v2-hotspot-label-y: -70px; --v2-hotspot-line-length: 139px; --v2-hotspot-line-angle: -150deg; } +.console-v2 .v2-twin-hotspots [data-twin-device="ambient"] { top: 62%; left: 66%; --v2-hotspot-label-x: -30px; --v2-hotspot-label-y: 85px; --v2-hotspot-line-length: 90px; --v2-hotspot-line-angle: 109deg; } +.console-v2 .v2-twin-hotspots [data-twin-device="headlight"] { top: 66%; left: 86%; --v2-hotspot-label-x: 15px; --v2-hotspot-label-y: 70px; --v2-hotspot-line-length: 72px; --v2-hotspot-line-angle: 78deg; } +.console-v2 .v2-twin-hotspots [data-twin-device="climate"] { top: 48%; left: 73%; --v2-hotspot-label-x: 94px; --v2-hotspot-label-y: 5px; --v2-hotspot-line-length: 94px; --v2-hotspot-line-angle: 3deg; } @media (max-width: 1200px) { .console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell.v2-output-page { @@ -6633,3 +6767,786 @@ body.device-power-drawer-open { transition: none; } } + +/* Actionable vehicle device status cards -------------------------------- */ +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-sync-grid { + display: flex; + flex-direction: column; + align-content: start; + padding-right: 3px; + overflow-x: hidden; + overflow-y: auto; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device { + flex: 0 0 auto; + min-height: 0; + display: block; + padding: 8px; + text-align: left; + cursor: default; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device:hover, +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device.active { + transform: none; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device[data-control-open="true"] { + background: linear-gradient(155deg, #eff9f6, #fbfdfc); + border-color: rgba(13, 126, 117, 0.24); + box-shadow: 0 9px 22px rgba(29, 100, 94, 0.1); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device[data-has-draft="true"] { + border-color: rgba(209, 143, 44, 0.3); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 5px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus { + min-width: 0; + display: grid; + grid-template-columns: 28px minmax(0, 1fr) 6px; + align-items: center; + gap: 7px; + padding: 0; + color: inherit; + background: transparent; + border: 0; + text-align: left; + cursor: pointer; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus > span:first-child { + width: 28px; + height: 28px; + display: grid; + place-items: center; + color: #16877f; + background: #dff2ee; + border-radius: 9px; + transition: color 150ms ease, background 150ms ease; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus:hover > span:first-child, +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus:focus-visible > span:first-child { + color: #ffffff; + background: #12857d; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus svg { + width: 14px; + height: 14px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus > span:nth-child(2) { + min-width: 0; + display: grid; + gap: 2px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus strong { + color: #1d3a3b; + font-size: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus small { + overflow: hidden; + color: #7a908e; + font-size: 7px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device[data-has-draft="true"] .v2-device-card-focus small { + color: #a16b1b; + font-weight: 800; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus .v2-device-signal { + width: 6px; + height: 6px; + background: #aebcba; + border-radius: 50%; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device[data-ready="true"] .v2-device-card-focus .v2-device-signal { + background: #18a783; + box-shadow: 0 0 0 3px rgba(24, 167, 131, 0.1); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-adjust-toggle { + min-width: 43px; + height: 25px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + padding: 0 6px; + color: #537572; + background: #f4f9f8; + border: 1px solid rgba(17, 108, 101, 0.12); + border-radius: 8px; + font-size: 7px; + font-weight: 850; + cursor: pointer; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-adjust-toggle:hover, +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-adjust-toggle[aria-expanded="true"] { + color: #ffffff; + background: #117e77; + border-color: #117e77; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-adjust-toggle svg { + width: 11px; + height: 11px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + margin: 7px 0 0; + padding: 7px 0 0; + border-top: 1px solid rgba(35, 105, 99, 0.08); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device dl div { + min-width: 0; + display: grid; + gap: 2px; + padding: 5px 6px; + background: rgba(239, 247, 245, 0.72); + border-radius: 7px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device dl div:only-child { + grid-column: 1 / -1; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device dt, +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device dd { + min-width: 0; + overflow: hidden; + margin: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device dt { + color: #80928f; + font-size: 6.5px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device dd { + color: #294e4b; + font-size: 8px; + font-weight: 850; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-controls { + display: grid; + gap: 7px; + margin-top: 8px; + padding: 8px; + background: linear-gradient(155deg, rgba(224, 243, 239, 0.9), rgba(242, 249, 247, 0.96)); + border: 1px solid rgba(20, 117, 109, 0.12); + border-radius: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-controls[hidden] { + display: none !important; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-controls-intro { + display: grid; + gap: 2px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(22, 111, 104, 0.1); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-controls-intro span { + color: #166f69; + font-size: 8px; + font-weight: 900; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-controls-intro small { + color: #718783; + font-size: 6.5px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-row { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 4px 7px; + color: #456764; + font-size: 7px; + font-weight: 800; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-row output { + color: #0e756f; + font: 850 7px/1 "SFMono-Regular", Consolas, monospace; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-row input[type="range"] { + width: 100%; + height: 15px; + grid-column: 1 / -1; + margin: 0; + accent-color: #0f8179; + cursor: pointer; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-row > small { + grid-column: 1 / -1; + color: #899a97; + font-size: 6px; + font-weight: 650; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-select select { + width: 82px; + height: 25px; + padding: 0 5px; + color: #2f5753; + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(21, 108, 101, 0.14); + border-radius: 7px; + outline: none; + font-size: 7px; + font-weight: 800; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-select select:focus { + border-color: #16877f; + box-shadow: 0 0 0 2px rgba(22, 135, 127, 0.1); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-apply { + width: 100%; + min-height: 29px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + margin-top: 1px; + color: #ffffff; + background: linear-gradient(135deg, #138b82, #0a706a); + border: 0; + border-radius: 8px; + box-shadow: 0 6px 14px rgba(14, 119, 112, 0.18); + font-size: 8px; + font-weight: 900; + cursor: pointer; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-apply:hover:not(:disabled) { + background: linear-gradient(135deg, #15988e, #0b7770); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-apply:disabled { + color: #6e8985; + background: #d8e7e4; + box-shadow: none; + cursor: wait; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-apply svg { + width: 12px; + height: 12px; +} + +/* Readability and final layout alignment -------------------------------- */ +.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .command-composer { + height: 88px; + min-height: 88px; + position: relative; + overflow: hidden; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form #utterance { + height: 88px; + min-height: 88px; + padding: 11px 72px 36px 14px; + font-size: 13px; + line-height: 1.5; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .primary-action { + width: 36px; + height: 36px; + min-height: 36px; + top: 50%; + right: 10px; + bottom: auto; + transform: translateY(-50%); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .primary-action:hover:not(:disabled), +.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .primary-action:focus-visible { + transform: translateY(-50%) scale(1.04); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .context-reset { + top: 7px; + right: 52px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .composer-hint { + right: 56px; + bottom: 8px; + font-size: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-reasoning-trigger { + min-height: 28px; + font-size: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-reasoning-trigger > strong { + font-size: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-natural-panel { + grid-template-rows: auto minmax(88px, 1fr) auto; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-link span { + font-size: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-log header small, +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-log time { + font-size: 8.5px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-log select { + font-size: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-log li strong { + font-size: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-log li small { + font-size: 9px; + line-height: 1.4; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-heading .section-kicker, +.console-v2.v2-passive-display.v2-interactive-sync .twin-orientation, +.console-v2.v2-passive-display.v2-interactive-sync .twin-seat-switch button { + font-size: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell.v2-output-page, +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="devices extensions"] { + grid-template-columns: minmax(234px, 0.56fr) minmax(460px, 1.5fr) minmax(234px, 0.56fr); +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="devices"] { + grid-template-columns: minmax(224px, 0.58fr) minmax(460px, 1.42fr) 48px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="extensions"] { + grid-template-columns: 48px minmax(460px, 1.42fr) minmax(224px, 0.58fr); +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="none"] { + grid-template-columns: 48px minmax(520px, 1fr) 48px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device { + padding: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-header { + gap: 7px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus { + grid-template-columns: 30px minmax(0, 1fr) 6px; + gap: 8px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus > span:first-child { + width: 30px; + height: 30px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus strong { + font-size: 11px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-card-focus small { + font-size: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-adjust-toggle { + min-width: 48px; + height: 29px; + padding: 0 7px; + font-size: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-adjust-toggle svg { + width: 12px; + height: 12px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device dl { + gap: 6px; + margin-top: 9px; + padding-top: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device dl div { + gap: 3px; + min-height: 38px; + padding: 7px 8px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device dt { + font-size: 9px; + line-height: 1.2; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device dd { + font-size: 10px; + line-height: 1.25; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-controls { + gap: 9px; + margin-top: 10px; + padding: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-controls-intro span { + font-size: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-controls-intro small, +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-row, +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-row output { + font-size: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-row > small { + font-size: 8.5px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-select select { + width: 92px; + height: 30px; + font-size: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-control-apply { + min-height: 34px; + font-size: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-sync-heading small, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-gateway > b, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-synced-device header small, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-synced-device header b, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-synced-device > div span, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-linkage small, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-linkage > span { + font-size: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-gateway strong, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-synced-device header strong { + font-size: 11px; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-gateway small, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-empty span { + font-size: 9px; + line-height: 1.4; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-empty strong, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-linkage strong { + font-size: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-status-panel.is-collapsed { + padding: 0; + overflow: hidden; + background: rgba(250, 253, 252, 0.96); + border: 1px solid rgba(17, 112, 105, 0.14); + border-radius: 16px; + box-shadow: 0 8px 20px rgba(35, 78, 75, 0.06); +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2DevicePanel.is-collapsed, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel.is-collapsed { + border-radius: 16px; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2DevicePanel.is-collapsed > header, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel.is-collapsed .v2-extension-sync-mount, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel.is-collapsed .v2-extension-sync-heading { + width: 100%; + height: 100%; + min-height: 0; + margin: 0; + padding: 0; + overflow: hidden; + background: transparent; + border: 0; + border-radius: 15px; + box-shadow: none; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2DevicePanel.is-collapsed > header { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + place-items: stretch; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2DevicePanel.is-collapsed > header > button, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel.is-collapsed .v2-extension-sync-heading > button { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + margin: 0; + padding: 12px 0; + color: #216f6a; + background: transparent; + border: 0; + border-radius: 15px; + writing-mode: horizontal-tb; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2DevicePanel.is-collapsed > header > button { + grid-column: 1; + grid-row: 1; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2DevicePanel.is-collapsed > header > button span, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel.is-collapsed .v2-extension-sync-heading > button span { + font-size: 10px; + font-weight: 850; + letter-spacing: 0.16em; + writing-mode: vertical-rl; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2DevicePanel.is-collapsed > header > button:hover, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel.is-collapsed .v2-extension-sync-heading > button:hover { + color: #0b756f; + background: #e6f5f2; +} + +@media (max-width: 1120px) { + .console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell.v2-output-page, + .console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="devices extensions"] { + grid-template-columns: minmax(210px, 0.58fr) minmax(420px, 1.35fr) minmax(210px, 0.58fr); + } +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-mode-rail .mode-gallery-toggle strong, +.console-v2.v2-passive-display.v2-interactive-sync .v2-task-timeline i, +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-seat-label small, +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-callout span, +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-callout small, +.console-v2.v2-passive-display.v2-interactive-sync .v2-twin-hotspots button, +.console-v2.v2-passive-display.v2-interactive-sync .v2-twin-device-popover header > b, +.console-v2.v2-passive-display.v2-interactive-sync .v2-twin-device-popover dt { + font-size: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-twin-device-popover header small { + font-size: 8.5px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-twin-device-popover dd { + font-size: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sidebar-footer small, +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-sync-board > header > button, +.console-v2.v2-passive-display.v2-interactive-sync .v2-extension-sync-heading > button { + font-size: 9px; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-gateway small { + overflow: visible; + text-overflow: clip; + white-space: normal; +} + +/* Collapsible left sync rail aligned with the lower workspace ------------ */ +.console-v2.v2-passive-display.v2-interactive-sync .workspace { + transition: grid-template-columns 180ms ease; +} + +.console-v2.v2-passive-display.v2-interactive-sync .command-panel.v2-sidebar { + --v2-sync-log-top: 291px; + position: relative; + display: block; + padding: 0 15px 13px; + overflow: hidden; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-sidebar { + width: 100%; + height: 100%; + display: grid; + grid-template-rows: calc(var(--v2-sync-log-top) - 10px) minmax(0, 1fr); + gap: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-sidebar-overview { + min-height: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: 10px; + padding-top: 26px; + overflow: hidden; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-sidebar-heading { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 32px; + grid-template-rows: auto auto auto; + column-gap: 7px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-sidebar-heading > small, +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-sidebar-heading > h2 { + min-width: 0; + grid-column: 1; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-sidebar-heading > p { + grid-column: 1 / -1; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-sidebar-heading h2 { + font-size: 19px; + white-space: nowrap; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2SidebarToggle { + width: 32px; + height: 28px; + min-width: 32px; + grid-column: 2; + grid-row: 1 / 3; + align-self: start; + display: inline-flex !important; + align-items: center; + justify-content: center; + gap: 3px; + padding: 0; + color: #3d716e; + background: rgba(255, 255, 255, 0.78); + border: 1px solid rgba(19, 105, 99, 0.12); + border-radius: 9px; + box-shadow: 0 3px 9px rgba(37, 84, 80, 0.055); + cursor: pointer; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2SidebarToggle:hover, +.console-v2.v2-passive-display.v2-interactive-sync #v2SidebarToggle:focus-visible { + color: #0b756f; + background: #e3f4f1; + border-color: rgba(13, 126, 117, 0.21); +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2SidebarToggle svg { + width: 14px; + height: 14px; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2SidebarToggle span { + display: none; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-sidebar-overview > .v2-sync-protocol { + align-self: end; + margin: 0; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-sidebar-overview > .v2-sidebar-footer { + width: 100%; + margin: 0; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-sidebar > .v2-sync-log { + min-height: 0; + grid-row: 2; + flex: none; +} + +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .workspace { + grid-template-columns: 64px minmax(0, 1fr); +} + +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .command-panel.v2-sidebar { + padding: 3px 8px 13px; +} + +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .v2-sync-sidebar, +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .v2-sync-sidebar-overview, +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .v2-sync-sidebar-heading { + width: 100%; + height: 100%; + display: block; + margin: 0; + padding: 0; +} + +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .v2-sync-sidebar-heading > small, +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .v2-sync-sidebar-heading > h2, +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .v2-sync-sidebar-heading > p, +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .v2-sync-link-stack, +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .v2-sync-protocol, +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .v2-sidebar-footer, +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .v2-sync-log { + display: none !important; +} + +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed #v2SidebarToggle { + width: 100%; + height: 100%; + min-width: 0; + display: flex !important; + flex-direction: column; + gap: 10px; + color: #216f6a; + background: rgba(250, 253, 252, 0.92); + border-radius: 16px; + box-shadow: 0 8px 20px rgba(35, 78, 75, 0.06); +} + +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed #v2SidebarToggle span { + display: block; + font-size: 10px; + font-weight: 850; + letter-spacing: 0.16em; + writing-mode: vertical-rl; +} diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js index 8083f9c..ba4b9ba 100644 --- a/apps/web-console/console-v2.js +++ b/apps/web-console/console-v2.js @@ -112,7 +112,11 @@ place(); }; reasoningPickerControl = { close, open, place, sync, trigger }; + const resetButton = $("#newConversation", originalForm); + const runButton = $("#runButton", originalForm); + if (resetButton) composer.append(resetButton); composer.append(trigger); + if (runButton) composer.append(runButton); document.body.append(reasoningField); trigger.addEventListener("click", (event) => { @@ -493,8 +497,14 @@ requestId: null, pollTimer: null, deviceFrame: null, + deviceFingerprint: "", activeDevice: null, pinnedDevice: null, + previewClearTimer: null, + hoverSuppressedUntil: 0, + controlDevice: null, + deviceDrafts: {}, + pendingControls: {}, logs: [], logKeys: new Set(), extensionFingerprint: "", @@ -502,55 +512,172 @@ const readText = (selector, fallback = "--") => $(selector)?.textContent?.trim() || fallback; - function seatDeviceRows(seat) { - const card = $(`.seat-card[data-seat="${seat}"]`); - if (!card) return [["状态", "等待反馈"]]; - const rows = [ - ["反馈通道", $("[data-seat-feedback]", card)?.textContent?.trim() || "等待反馈"], - ...$$('[data-function]', card).map((item) => [ - item.textContent.replace(item.querySelector("b")?.textContent || "", "").trim(), - $("b", item)?.textContent?.trim() || "--", - ]), - ...$$('.motion-axis', card).map((item) => [ - $("span", item)?.textContent?.trim() || "姿态", - $("b", item)?.textContent?.trim() || "--", - ]), - ]; - return rows.filter(([label]) => label); + const clampControlNumber = (value, min, max, fallback = min) => { + const numeric = Number.parseFloat(String(value ?? "").replace(/[^\d.-]/g, "")); + return Number.isFinite(numeric) ? Math.max(min, Math.min(max, numeric)) : fallback; + }; + + const splitReadout = (selector) => { + const [front = "-", rear = "-"] = readText(selector, "- / -").split("/"); + return [front.trim() || "-", rear.trim() || "-"]; + }; + + function seatFunctionValue(seat, feature) { + return $(`.seat-card[data-seat="${seat}"] [data-function="${feature}"] b`)?.textContent?.trim() || "--"; + } + + function seatAxisValue(seat, axis) { + return $(`.seat-card[data-seat="${seat}"] [data-axis="${axis}"] b`)?.textContent?.trim() || "--"; + } + + function seatLevelControlValue(seat, feature) { + const text = seatFunctionValue(seat, feature); + const level = text.match(/L(\d+)/i)?.[1]; + return /OFF|关闭|--/.test(text) ? "off" : level || "1"; + } + + function massageReadout() { + const text = seatFunctionValue("driver", "massage"); + if (text === "--") return ["--", "--"]; + if (/OFF|关闭/.test(text)) return ["关闭", "关闭"]; + const [mode = "开启", strength = "默认"] = text.split("·").map((item) => item.trim()); + return [mode, strength]; + } + + const levelOptions = [ + ["off", "关闭"], ["1", "1 档"], ["2", "2 档"], ["3", "3 档"], + ]; + const toggleOptions = [["off", "关闭"], ["on", "开启"]]; + const massageModeOptions = [ + ["off", "关闭"], ["relax", "舒缓"], ["stretch", "伸展"], ["waist", "腰部"], ["wave", "波浪"], + ]; + const massageStrengthOptions = [ + ["off", "关闭"], ["low", "低"], ["medium", "中"], ["high", "高"], + ]; + const colorIndexNames = ["皓月白", "冰川蓝", "松石绿", "星云紫", "琥珀橙", "玫瑰红"]; + const colorIndexOptions = Array.from({ length: 16 }, (_item, index) => [ + String(index), colorIndexNames[index] ? `${index} · ${colorIndexNames[index]}` : `色号 ${index}`, + ]); + + const rangeControl = (key, label, value, min = 0, max = 100, step = 1, unit = "%", hint = "") => ({ + key, label, type: "range", value: clampControlNumber(value, min, max), min, max, step, unit, hint, + }); + const selectControl = (key, label, value, options) => ({ key, label, type: "select", value, options }); + + function deviceControlValue(deviceId, control) { + const draft = displaySync.deviceDrafts[deviceId]; + return draft && Object.hasOwn(draft, control.key) ? draft[control.key] : control.value; + } + + function optionLabel(options, value) { + return options.find(([optionValue]) => String(optionValue) === String(value))?.[1] || String(value); } const syncedDevices = [ + { + id: "passenger", name: "副驾座椅", icon: "armchair", + rows: () => [["通风", seatFunctionValue("passenger", "ventilation")], ["加热", seatFunctionValue("passenger", "heating")], ["水平", seatAxisValue("passenger", "longitudinal")], ["坐垫", seatAxisValue("passenger", "cushion_tilt")], ["靠背", seatAxisValue("passenger", "backrest_recline")], ["腿托", seatAxisValue("passenger", "legrest_height")]], + controls: () => [ + selectControl("ventilation", "通风", seatLevelControlValue("passenger", "ventilation"), levelOptions), + selectControl("heating", "加热", seatLevelControlValue("passenger", "heating"), levelOptions), + rangeControl("longitudinal", "水平", seatAxisValue("passenger", "longitudinal")), + rangeControl("cushion_tilt", "坐垫", seatAxisValue("passenger", "cushion_tilt")), + rangeControl("backrest_recline", "靠背", seatAxisValue("passenger", "backrest_recline")), + rangeControl("legrest_height", "腿托", seatAxisValue("passenger", "legrest_height")), + ], + command: (values) => `调节副驾座椅:通风${optionLabel(levelOptions, values.ventilation)},加热${optionLabel(levelOptions, values.heating)},水平${values.longitudinal}%,坐垫${values.cushion_tilt}%,靠背${values.backrest_recline}%,腿托${values.legrest_height}%`, + }, + { + id: "driver", name: "主驾座椅", icon: "armchair", + rows: () => { + const [massageMode, massageStrength] = massageReadout(); + return [["通风", seatFunctionValue("driver", "ventilation")], ["加热", seatFunctionValue("driver", "heating")], ["按摩模式", massageMode], ["按摩强度", massageStrength]]; + }, + controls: () => { + const [massageMode, massageStrength] = massageReadout(); + return [ + selectControl("ventilation", "通风", seatLevelControlValue("driver", "ventilation"), levelOptions), + selectControl("heating", "加热", seatLevelControlValue("driver", "heating"), levelOptions), + selectControl("massage_mode", "按摩模式", /关闭|--/.test(massageMode) ? "off" : massageMode.toLowerCase(), massageModeOptions), + selectControl("massage_strength", "按摩强度", /关闭|--/.test(massageStrength) ? "off" : massageStrength.toLowerCase(), massageStrengthOptions), + ]; + }, + command: (values) => `调节主驾座椅:通风${optionLabel(levelOptions, values.ventilation)},加热${optionLabel(levelOptions, values.heating)},按摩模式${optionLabel(massageModeOptions, values.massage_mode)},按摩强度${optionLabel(massageStrengthOptions, values.massage_strength)}`, + }, { id: "windows", name: "车窗", icon: "gallery-vertical-end", - rows: () => [["设备状态", readText("#windowState", "等待反馈")], ["左前窗", readText("#frontLeftWindow")], ["右前窗", readText("#frontRightWindow")], ["左后窗", readText("#rearLeftWindow")], ["右后窗", readText("#rearRightWindow")]], + rows: () => [["左前窗", readText("#frontLeftWindow")], ["右前窗", readText("#frontRightWindow")], ["左后窗", readText("#rearLeftWindow")], ["右后窗", readText("#rearRightWindow")]], + controls: () => [ + rangeControl("front_left", "左前窗", readText("#frontLeftWindow"), 0, 100, 1, "%", "0% 关闭 · 100% 打开"), + rangeControl("front_right", "右前窗", readText("#frontRightWindow")), + rangeControl("rear_left", "左后窗", readText("#rearLeftWindow")), + rangeControl("rear_right", "右后窗", readText("#rearRightWindow")), + ], + command: (values) => `调节车窗开合比例:左前窗${values.front_left}%,右前窗${values.front_right}%,左后窗${values.rear_left}%,右后窗${values.rear_right}%`, }, { id: "shade", name: "遮阳帘", icon: "panels-top-left", - rows: () => [["设备状态", readText("#roofState", "等待反馈")], ["当前位置", readText("#shadePositionValue")], ["动作", readText("#shadeMotionState")], ["初始化", readText("#shadeInitializedState")], ["热保护", readText("#shadeThermalState")]], + rows: () => [["当前位置", readText("#shadePositionValue")]], + controls: () => [rangeControl("position", "当前位置", readText("#shadePositionValue"), 0, 100, 1, "%", "0% 关闭 · 100% 打开")], + command: (values) => `将遮阳帘开合位置调整为${values.position}%`, }, - { id: "driver", name: "主驾座椅", icon: "armchair", rows: () => seatDeviceRows("driver") }, - { id: "passenger", name: "副驾座椅", icon: "armchair", rows: () => seatDeviceRows("passenger") }, { id: "reading", name: "阅读灯", icon: "book-open", - rows: () => [["开关状态", readText("#interiorLightPower", "等待反馈")], ["控制通道", "CH1 · INTERIOR"], ["控制能力", "开 / 关"]], + rows: () => [["开关状态", readText("#interiorLightPower", "等待反馈")]], + controls: () => [selectControl("power", "开关状态", /开启|ON/.test(readText("#interiorLightPower")) ? "on" : "off", toggleOptions)], + command: (values) => `${values.power === "on" ? "打开" : "关闭"}阅读灯`, }, { id: "ambient", name: "氛围灯", icon: "sparkles", - rows: () => [["电源", readText("#ambientPower", "等待反馈")], ["模式", readText("#ambientMode")], ["前/后排亮度", readText("#ambientBrightness")], ["前/后排色号", readText("#ambientColorIndex")]], + rows: () => { + const [frontBrightness, rearBrightness] = splitReadout("#ambientBrightness"); + const [frontColor, rearColor] = splitReadout("#ambientColorIndex"); + return [["开关状态", /OFF|关闭/.test(readText("#ambientPower")) ? "关闭" : "开启"], ["前排亮度", frontBrightness], ["前排颜色", frontColor], ["后排亮度", rearBrightness], ["后排颜色", rearColor]]; + }, + controls: () => { + const [frontBrightness, rearBrightness] = splitReadout("#ambientBrightness"); + const [frontColor, rearColor] = splitReadout("#ambientColorIndex"); + return [ + selectControl("power", "开关状态", /OFF|关闭/.test(readText("#ambientPower")) ? "off" : "on", toggleOptions), + rangeControl("front_brightness", "前排亮度", frontBrightness, 0, 5, 1, " 级"), + selectControl("front_color", "前排颜色", String(clampControlNumber(frontColor, 0, 15, 0)), colorIndexOptions), + rangeControl("rear_brightness", "后排亮度", rearBrightness, 0, 5, 1, " 级"), + selectControl("rear_color", "后排颜色", String(clampControlNumber(rearColor, 0, 15, 0)), colorIndexOptions), + ]; + }, + command: (values) => `调节氛围灯:${values.power === "on" ? "开启" : "关闭"},前排亮度${values.front_brightness}级、颜色${optionLabel(colorIndexOptions, values.front_color)},后排亮度${values.rear_brightness}级、颜色${optionLabel(colorIndexOptions, values.rear_color)}`, }, { id: "headlight", name: "前大灯", icon: "car-front", - rows: () => [["设备状态", readText("#frontLightState", "等待反馈")], ["近光灯", readText("#lowBeamState")], ["远光灯", readText("#highBeamState")], ["雾灯", readText("#fogLightState")], ["转向灯", readText("#turnSignalState")]], + rows: () => [["近光灯", readText("#lowBeamState")], ["远光灯", readText("#highBeamState")], ["雾灯", readText("#fogLightState")], ["转向灯", readText("#turnSignalState")]], + controls: () => [["low_beam", "近光灯", "#lowBeamState"], ["high_beam", "远光灯", "#highBeamState"], ["fog_light", "雾灯", "#fogLightState"], ["turn_signal", "转向灯", "#turnSignalState"]].map(([key, label, selector]) => selectControl(key, label, /开启|ON/.test(readText(selector)) ? "on" : "off", toggleOptions)), + command: (values) => `调节前大灯:近光灯${optionLabel(toggleOptions, values.low_beam)},远光灯${optionLabel(toggleOptions, values.high_beam)},雾灯${optionLabel(toggleOptions, values.fog_light)},转向灯${optionLabel(toggleOptions, values.turn_signal)}`, }, { id: "climate", name: "空调", icon: "fan", - rows: () => [["电源", readText("#climatePower", "等待反馈")], ["主驾温度", readText("#driverTemperature")], ["副驾温度", readText("#passengerTemperature")], ["风量", readText("#fanLevel")], ["主驾风向", readText("#driverAirDirection")], ["副驾风向", readText("#passengerAirDirection")], ["循环", readText("#cycleMode")], ["A/C", readText("#acState")], ["前除霜", readText("#defrostState")]], + rows: () => [["开关状态", readText("#climatePower", "等待反馈")], ["主驾温度", readText("#driverTemperature")], ["副驾温度", readText("#passengerTemperature")], ["风量", readText("#fanLevel")], ["循环模式", readText("#cycleMode")]], + controls: () => [ + selectControl("power", "开关状态", /ON|ACTIVE|开启/.test(readText("#climatePower")) ? "on" : "off", toggleOptions), + rangeControl("driver_temperature", "主驾温度", readText("#driverTemperature"), 16, 32, 0.5, "℃"), + rangeControl("passenger_temperature", "副驾温度", readText("#passengerTemperature"), 16, 32, 0.5, "℃"), + rangeControl("fan_level", "风量", readText("#fanLevel"), 0, 7, 1, " 档"), + selectControl("cycle_mode", "循环模式", readText("#cycleMode").includes("内") ? "internal" : "external", [["internal", "内循环"], ["external", "外循环"]]), + ], + command: (values) => `调节空调:${values.power === "on" ? "开启" : "关闭"},主驾${values.driver_temperature}℃,副驾${values.passenger_temperature}℃,风量${values.fan_level}档,${values.cycle_mode === "internal" ? "内循环" : "外循环"}`, }, ]; - function deviceSummary(device) { - const values = device.rows().map(([, value]) => value).filter(Boolean); - return values.slice(0, 3).join(" · ") || "等待反馈"; + function renderDeviceControl(device, control) { + const value = deviceControlValue(device.id, control); + if (control.type === "select") { + const options = control.options.map(([optionValue, label]) => ``).join(""); + return ``; + } + return ``; } function renderSyncedDevices() { @@ -558,25 +685,83 @@ if (!grid) return; grid.innerHTML = syncedDevices.map((device) => { const rows = device.rows(); - const detail = deviceSummary(device); - const waiting = /--|等待|同步中|未知/.test(detail); - return ``; + const waiting = rows.some(([, value]) => /--|等待|同步中|未知/.test(value)); + const controlOpen = displaySync.controlDevice === device.id; + const hasDraft = Boolean(displaySync.deviceDrafts[device.id]); + const pending = Boolean(displaySync.pendingControls[device.id]); + const stateLabel = pending ? "已发送 · 等待同步" : hasDraft ? "参数待发送" : `${rows.length} 项关键状态`; + return `
+
+ + +
+
${rows.map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`).join("")}
+
+
手动调节参数确认后通过编排链路发送至车机
+ ${device.controls().map((control) => renderDeviceControl(device, control)).join("")} + +
+
`; }).join(""); if (displaySync.activeDevice) { $(`.v2-synced-device[data-device="${displaySync.activeDevice}"]`)?.classList.add("active"); } + displaySync.deviceFingerprint = JSON.stringify(syncedDevices.map((device) => [device.id, device.rows()])); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); } + function storeDeviceControlDraft(input) { + const deviceId = input.dataset.deviceControl; + const key = input.dataset.controlKey; + if (!deviceId || !key) return; + displaySync.deviceDrafts[deviceId] ||= {}; + displaySync.deviceDrafts[deviceId][key] = input.value; + const controls = input.closest("[data-device-controls]"); + const output = controls?.querySelector(`[data-control-output="${key}"]`); + if (output) output.textContent = `${input.value}${input.dataset.controlUnit || ""}`; + const card = input.closest("[data-device]"); + if (card) { + card.dataset.hasDraft = "true"; + const state = $("[data-device-card-state]", card); + if (state) state.textContent = "参数待发送"; + } + window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-control-draft", { + detail: { device_id: deviceId, control: key, value: input.value, source: "web_console" }, + })); + } + + function sendDeviceControl(deviceId) { + const device = syncedDevices.find((item) => item.id === deviceId); + if (!device) return; + const values = Object.fromEntries(device.controls().map((control) => [control.key, String(deviceControlValue(device.id, control))])); + const utterance = device.command(values); + const detail = { + device_id: device.id, + device_name: device.name, + controls: values, + utterance, + source: "web_console", + requested_at: new Date().toISOString(), + }; + window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-control-request", { detail })); + const accepted = window.CognitiveAgentConsole?.executeUtterance?.(utterance, { autoSubmit: true }) || false; + if (!accepted) { + window.CognitiveAgentConsole?.notify?.("上一条指令仍在执行,请稍候"); + return; + } + displaySync.pendingControls[device.id] = utterance; + appendSyncLog({ category: "device", title: `网页端调节${device.name}`, detail: utterance, status: "info" }); + renderSyncedDevices(); + } + function scheduleSyncedDeviceRender() { if (displaySync.deviceFrame) return; displaySync.deviceFrame = requestAnimationFrame(() => { displaySync.deviceFrame = null; + const fingerprint = JSON.stringify(syncedDevices.map((device) => [device.id, device.rows()])); + if (fingerprint === displaySync.deviceFingerprint && $("#v2DeviceSyncGrid")?.childElementCount) return; renderSyncedDevices(); if (displaySync.activeDevice) presentTwinDevice(displaySync.activeDevice, { pinned: Boolean(displaySync.pinnedDevice), @@ -655,6 +840,10 @@ const device = syncedDevices.find((item) => item.id === deviceId); const popover = $("#v2TwinDevicePopover"); if (!device || !popover) return; + if (displaySync.previewClearTimer) { + window.clearTimeout(displaySync.previewClearTimer); + displaySync.previewClearTimer = null; + } displaySync.activeDevice = deviceId; if (pinned) displaySync.pinnedDevice = deviceId; const rows = device.rows(); @@ -662,7 +851,7 @@ popover.dataset.device = deviceId; popover.dataset.pinned = String(Boolean(displaySync.pinnedDevice)); popover.innerHTML = ` -
LIVE DEVICE FEEDBACK${escapeHtml(device.name)}
${pinned ? "已固定" : "靠近查看"}
+
LIVE DEVICE FEEDBACK${escapeHtml(device.name)}
${pinned ? "已固定" : "靠近查看"}
${rows.map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`).join("")}
`; $$("[data-twin-device]").forEach((button) => button.classList.toggle("active", button.dataset.twinDevice === deviceId)); $$(".v2-synced-device").forEach((button) => button.classList.toggle("active", button.dataset.device === deviceId)); @@ -673,6 +862,10 @@ } function clearTwinDevicePreview({ force = false } = {}) { + if (displaySync.previewClearTimer) { + window.clearTimeout(displaySync.previewClearTimer); + displaySync.previewClearTimer = null; + } if (displaySync.pinnedDevice && !force) return; displaySync.activeDevice = null; if (force) displaySync.pinnedDevice = null; @@ -681,30 +874,42 @@ $$("[data-twin-device], .v2-synced-device").forEach((button) => button.classList.remove("active")); } + function scheduleTwinDevicePreviewClear() { + if (displaySync.pinnedDevice) return; + if (displaySync.previewClearTimer) window.clearTimeout(displaySync.previewClearTimer); + displaySync.previewClearTimer = window.setTimeout(() => { + displaySync.previewClearTimer = null; + clearTwinDevicePreview(); + }, 45); + } + function buildTwinDeviceInteractions() { const stage = $("#seatTwin"); if (!stage || $("#v2TwinHotspots")) return; const hotspots = make("div", "v2-twin-hotspots", syncedDevices.map((device) => ` - `).join("")); + `).join("")); hotspots.id = "v2TwinHotspots"; const popover = make("aside", "v2-twin-device-popover", ""); popover.id = "v2TwinDevicePopover"; popover.hidden = true; + popover.setAttribute("role", "dialog"); + popover.setAttribute("aria-label", "车端设备状态详情"); popover.setAttribute("aria-live", "polite"); stage.append(hotspots, popover); hotspots.addEventListener("pointerdown", (event) => event.stopPropagation()); - hotspots.addEventListener("pointerover", (event) => { - const button = event.target.closest("[data-twin-device]"); - if (button && !displaySync.pinnedDevice) presentTwinDevice(button.dataset.twinDevice); - }); - hotspots.addEventListener("focusin", (event) => { - const button = event.target.closest("[data-twin-device]"); - if (button) presentTwinDevice(button.dataset.twinDevice); - }); - hotspots.addEventListener("pointerout", (event) => { - if (event.relatedTarget?.closest?.("#v2TwinDevicePopover")) return; - window.setTimeout(() => clearTwinDevicePreview(), 100); + $$("[data-twin-device]", hotspots).forEach((button) => { + button.addEventListener("pointerenter", () => { + if (!displaySync.pinnedDevice && performance.now() >= displaySync.hoverSuppressedUntil) { + presentTwinDevice(button.dataset.twinDevice); + } + }); + button.addEventListener("pointerleave", (event) => { + if (event.relatedTarget?.closest?.("#v2TwinDevicePopover")) return; + scheduleTwinDevicePreviewClear(); + }); + button.addEventListener("focus", () => presentTwinDevice(button.dataset.twinDevice)); + button.addEventListener("blur", scheduleTwinDevicePreviewClear); }); hotspots.addEventListener("click", (event) => { const button = event.target.closest("[data-twin-device]"); @@ -715,6 +920,23 @@ else presentTwinDevice(button.dataset.twinDevice, { pinned: true }); }); popover.addEventListener("pointerdown", (event) => event.stopPropagation()); + popover.addEventListener("pointerenter", () => { + if (displaySync.previewClearTimer) { + window.clearTimeout(displaySync.previewClearTimer); + displaySync.previewClearTimer = null; + } + }); + popover.addEventListener("pointerleave", scheduleTwinDevicePreviewClear); + popover.addEventListener("click", (event) => { + if (!event.target.closest("[data-twin-popover-close]")) return; + event.stopPropagation(); + displaySync.hoverSuppressedUntil = performance.now() + 160; + clearTwinDevicePreview({ force: true }); + }); + stage.addEventListener("click", (event) => { + if (event.target.closest("[data-twin-device], #v2TwinDevicePopover")) return; + clearTwinDevicePreview({ force: true }); + }); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); } @@ -892,6 +1114,14 @@ }); } + function syncSidebarLogBaseline() { + const sidebar = $(".v2-sidebar"); + const lowerPanel = $("#v2DevicePanel") || $(".joint-twin-shell > .twin-band"); + if (!sidebar || !lowerPanel) return; + const offset = Math.max(0, lowerPanel.getBoundingClientRect().top - sidebar.getBoundingClientRect().top); + sidebar.style.setProperty("--v2-sync-log-top", `${offset}px`); + } + function buildSyncControlLayer() { document.body.classList.add("v2-passive-display", "v2-interactive-sync"); document.body.dataset.module = "self-orchestration"; @@ -900,18 +1130,25 @@ const sidebar = $(".v2-sidebar"); const sidebarFooter = $(".v2-sidebar-footer", sidebar); const syncSidebar = make("section", "v2-sync-sidebar", ` -
REMOTE CLIENT

511工程-智能座舱

网页可远程编排,车机任务、设备反馈与 USB 外设能力保持同步。

-