diff --git a/apps/web-console/README.md b/apps/web-console/README.md index 537a5f0..766e8e9 100755 --- a/apps/web-console/README.md +++ b/apps/web-console/README.md @@ -4,7 +4,7 @@ Web 控制台提供一句话、快捷功能模式与手势控制入口,不在 当前页面包括: -- 顶部“自编排 / 自标定 / 自扩展 / 自修复”模块切换;user 进入自标定或自修复时自动切换为 admin,自修复停留期间不能降级为 user;自扩展包含外设学习与统一控制两种工作模式。 +- 顶部“自编排 / 自标定 / 自扩展 / 自修复 / 自进化”模块切换;user 进入自标定或自修复时自动切换为 admin,自修复停留期间不能降级为 user;自扩展包含外设学习与统一控制两种工作模式,自进化同时面向 user 与 admin,以版本时间轴、改进证据和下一步计划呈现能力演进过程。 - 六类外设玩具的添加、红外能力学习、测试发射、单动作执行与本地能力目录。 - 基于已学习外设能力和车端能力的 GPT 联动计划,车端步骤复用现有安全编排链路。 - 一句话联动座椅、空调、天幕和氛围灯,支持 Enter 执行和 GPT/本地规则切换。 diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 0499acc..e5bee40 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -137,18 +137,475 @@ function initJointTwinLayout() { if (seatFeedback) coverage.prepend(seatFeedback); domainGrid.prepend(driverCard); domainGrid.prepend(passengerCard); - const updateDomainGridScrollState = () => { - const isScrollable = domainGrid.scrollHeight > domainGrid.clientHeight + 2; - const isAtBottom = domainGrid.scrollTop + domainGrid.clientHeight >= domainGrid.scrollHeight - 2; - domainGrid.dataset.scrollable = String(isScrollable); - domainGrid.dataset.atBottom = String(!isScrollable || isAtBottom); + + const vehicleStatusPanel = $('[data-domain-panel="vehicle_status"]', domainGrid); + if (vehicleStatusPanel) { + vehicleStatusPanel.classList.add("cabin-telemetry-source"); + vehicleStatusPanel.hidden = true; + cabinBand.append(vehicleStatusPanel); + } + + cabinBand.classList.add("cabin-compact-board"); + const cabinHeading = $(".cabin-heading", cabinBand); + const cabinHeadingActions = document.createElement("div"); + cabinHeadingActions.className = "cabin-heading-actions"; + cabinHeadingActions.innerHTML = ` + 通信正常 · 7 个设备在线 + `; + cabinHeading?.append(cabinHeadingActions); + + const cabinToolbar = document.createElement("div"); + cabinToolbar.className = "cabin-board-toolbar"; + cabinToolbar.innerHTML = ` +
+
当前档位
--
+
实时车速
-- km/h
+
门锁状态
状态未知
+
系统状态
无故障
+
+ `; + cabinBand.insertBefore(cabinToolbar, domainGrid); + + const powerEntryDevices = [ + { + card: passengerCard, + key: "passenger_seat", + name: "副驾座椅", + measurable: true, + components: [ + ["longitudinal_motor", "水平电机"], + ["cushion_motor", "坐盆电机"], + ["backrest_motor", "座椅靠背电机"], + ["legrest_motor", "腿托电机"], + ["ventilation_control", "通风控制"], + ["heating_control", "加热控制"], + ], + }, + { card: driverCard, key: "driver_seat", name: "主驾座椅", measurable: true, components: [] }, + { + card: $('[data-domain-panel="climate"]', domainGrid), + key: "climate", + name: "空调", + measurable: true, + components: [ + ["compressor", "压缩机"], + ["electronic_fan", "电子风扇"], + ["blower", "鼓风机"], + ["thermal_management_module", "空调热管理集成模块"], + ["compressor_controller", "压缩机电控"], + ["electronic_cooling_pump", "电控冷却水泵"], + ], + }, + { card: $('[data-domain-panel="roof"]', domainGrid), key: "roof", name: "遮阳帘", measurable: true, components: [] }, + { card: $('[data-domain-panel="ambient_light"]', domainGrid), key: "ambient_light", name: "车内灯光", measurable: false, components: [] }, + { + card: $('[data-domain-panel="window"]', domainGrid), + key: "window", + name: "车窗", + measurable: true, + components: [ + ["front_left_window", "左前车窗"], + ["front_right_window", "右前车窗"], + ["rear_left_window", "左后车窗"], + ["rear_right_window", "右后车窗"], + ], + }, + { + card: $('[data-domain-panel="front_light"]', domainGrid), + key: "front_light", + name: "前大灯", + measurable: true, + components: [ + ["front_left_combination_lamp", "左前组合灯"], + ["front_right_combination_lamp", "右前组合灯"], + ], + }, + ]; + const powerDeviceByKey = new Map(powerEntryDevices.map((device) => [device.key, device])); + const powerTelemetryByDevice = new Map(); + let activePowerDevice = null; + let activePowerTrendRange = "trip"; + let powerDrawerReturnFocus = null; + + const powerBackdrop = document.createElement("div"); + powerBackdrop.className = "device-power-backdrop"; + powerBackdrop.id = "devicePowerBackdrop"; + powerBackdrop.hidden = true; + const powerDrawer = document.createElement("aside"); + powerDrawer.className = "device-power-drawer"; + powerDrawer.id = "devicePowerDrawer"; + powerDrawer.hidden = true; + powerDrawer.setAttribute("role", "dialog"); + powerDrawer.setAttribute("aria-modal", "true"); + powerDrawer.setAttribute("aria-labelledby", "devicePowerTitle"); + powerDrawer.innerHTML = ` +
+
+ DEVICE POWER INSIGHT +

设备功率详情

+

实时更新 · 等待功率数据

+
+ +
+
+
+
+ 当前总功率 + --W + 等待测量数据 +
+
+
本次行程
-- kWh
+
今日累计
-- kWh
+
+
+
+
+
POWER BREAKDOWN

子部件当前功率

+ 合计 -- W +
+
+
+
+
+
POWER TREND

功率趋势

+
+ + + +
+
+
+ + -- W + 0 +
暂无功率趋势数据
+
行程开始当前
+
+
+ +
`; + document.body.append(powerBackdrop, powerDrawer); + + const finitePowerNumber = (value) => { + if (value === null || value === undefined || value === "") return null; + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : null; }; - domainGrid.addEventListener("scroll", updateDomainGridScrollState, { passive: true }); + + const formatPower = (value) => { + const watts = finitePowerNumber(value); + if (watts === null) return "-- W"; + if (watts >= 1000) return `${(watts / 1000).toFixed(watts >= 10000 ? 1 : 2)} kW`; + return `${Math.round(watts)} W`; + }; + + const formatEnergy = (value) => { + const energy = finitePowerNumber(value); + return energy === null ? "-- kWh" : `${energy.toFixed(2)} kWh`; + }; + + const componentPower = (telemetry, key) => { + const value = telemetry?.components?.[key]; + return finitePowerNumber(value?.power_w ?? value); + }; + + const historyForRange = (telemetry, range) => { + const source = Array.isArray(telemetry?.history) + ? telemetry.history + : telemetry?.history?.[range]; + if (!Array.isArray(source)) return []; + return source + .map((item) => finitePowerNumber(item?.power_w ?? item?.value ?? item)) + .filter((item) => item !== null); + }; + + const renderPowerChart = (telemetry) => { + const history = historyForRange(telemetry, activePowerTrendRange); + const empty = $("#devicePowerChartEmpty", powerDrawer); + const line = $("#devicePowerChartLine", powerDrawer); + const area = $("#devicePowerChartArea", powerDrawer); + const maxLabel = $("#devicePowerChartMax", powerDrawer); + const labels = { + trip: ["行程开始", "当前"], + today: ["00:00", "当前"], + week: ["7天前", "今日"], + }; + $("#devicePowerChartStart", powerDrawer).textContent = labels[activePowerTrendRange][0]; + $("#devicePowerChartEnd", powerDrawer).textContent = labels[activePowerTrendRange][1]; + if (history.length < 2) { + line.setAttribute("points", ""); + area.setAttribute("d", ""); + maxLabel.textContent = "-- W"; + empty.hidden = false; + return; + } + const maxPower = Math.max(...history, 1); + const points = history.map((value, index) => { + const x = 36 + (468 * index) / (history.length - 1); + const y = 160 - (136 * value) / maxPower; + return [x, y]; + }); + line.setAttribute("points", points.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ")); + area.setAttribute("d", `M ${points[0][0]} 160 L ${points.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(" L ")} L ${points.at(-1)[0]} 160 Z`); + maxLabel.textContent = formatPower(maxPower); + empty.hidden = true; + }; + + const renderPowerDrawer = () => { + if (!activePowerDevice) return; + const telemetry = powerTelemetryByDevice.get(activePowerDevice.key) || {}; + const totalPower = finitePowerNumber(telemetry.power_w ?? telemetry.total_power_w); + const title = $("#devicePowerTitle", powerDrawer); + title.textContent = `${activePowerDevice.name}功率详情`; + const current = $("#devicePowerCurrent", powerDrawer); + const currentUnit = $("#devicePowerCurrentUnit", powerDrawer); + if (totalPower !== null && totalPower >= 1000) { + current.textContent = (totalPower / 1000).toFixed(totalPower >= 10000 ? 1 : 2); + currentUnit.textContent = "kW"; + } else { + current.textContent = totalPower === null ? "--" : String(Math.round(totalPower)); + currentUnit.textContent = "W"; + } + $("#devicePowerTripEnergy", powerDrawer).textContent = formatEnergy(telemetry.trip_energy_kwh); + $("#devicePowerTodayEnergy", powerDrawer).textContent = formatEnergy(telemetry.today_energy_kwh); + $("#devicePowerDataState", powerDrawer).textContent = totalPower === null ? "等待测量数据" : "设备总功率"; + const updatedAt = telemetry.updated_at ? new Date(telemetry.updated_at) : null; + const validUpdatedAt = updatedAt && !Number.isNaN(updatedAt.getTime()); + $("#devicePowerFreshness", powerDrawer).textContent = validUpdatedAt + ? `实时更新 · 最近更新 ${updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}` + : "实时更新 · 等待功率数据"; + + const componentList = $("#devicePowerComponentList", powerDrawer); + componentList.replaceChildren(); + const componentValues = activePowerDevice.components + .map(([key, label]) => ({ key, label, value: componentPower(telemetry, key) })); + const availableValues = componentValues.map((item) => item.value).filter((value) => value !== null); + const maxComponentPower = Math.max(...availableValues, 1); + if (!componentValues.length) { + const empty = document.createElement("div"); + empty.className = "device-power-components-empty"; + empty.innerHTML = ` + +
仅支持设备总功率

${activePowerDevice.name}暂无可读的子部件功率通道,顶部总功率仍可独立查看。

`; + componentList.append(empty); + $("#devicePowerComponentTotal", powerDrawer).textContent = "设备级测量"; + } else { + componentValues.forEach(({ label, value }) => { + const row = document.createElement("div"); + row.className = "device-power-component-row"; + row.innerHTML = ` + ${label} + + ${formatPower(value)}`; + componentList.append(row); + }); + const componentTotal = availableValues.length + ? availableValues.reduce((sum, value) => sum + value, 0) + : null; + $("#devicePowerComponentTotal", powerDrawer).textContent = `合计 ${formatPower(componentTotal)}`; + } + $("#devicePowerNote", powerDrawer).textContent = componentValues.length + ? "总功率为设备级实时测量值;子部件功率由各测量通道独立读取。" + : "该设备仅提供设备级总功率,暂无子部件功率测量通道。"; + renderPowerChart(telemetry); + refreshIcons(); + }; + + const closePowerDrawer = () => { + if (powerDrawer.hidden) return; + powerDrawer.hidden = true; + powerBackdrop.hidden = true; + document.body.classList.remove("device-power-drawer-open"); + powerDrawerReturnFocus?.focus(); + powerDrawerReturnFocus = null; + }; + + const openPowerDrawer = (device, trigger) => { + activePowerDevice = device; + activePowerTrendRange = "trip"; + powerDrawerReturnFocus = trigger; + $$('[data-power-trend-range]', powerDrawer).forEach((button) => { + button.classList.toggle("active", button.dataset.powerTrendRange === activePowerTrendRange); + }); + renderPowerDrawer(); + powerBackdrop.hidden = false; + powerDrawer.hidden = false; + document.body.classList.add("device-power-drawer-open"); + $("#devicePowerClose", powerDrawer)?.focus(); + }; + + $("#devicePowerClose", powerDrawer)?.addEventListener("click", closePowerDrawer); + powerBackdrop.addEventListener("click", closePowerDrawer); + $$('[data-power-trend-range]', powerDrawer).forEach((button) => { + button.addEventListener("click", () => { + activePowerTrendRange = button.dataset.powerTrendRange; + $$('[data-power-trend-range]', powerDrawer).forEach((tab) => { + tab.classList.toggle("active", tab === button); + }); + renderPowerChart(powerTelemetryByDevice.get(activePowerDevice?.key) || {}); + }); + }); + + const updatePowerTelemetry = (payload) => { + const updates = payload?.devices && typeof payload.devices === "object" + ? Object.entries(payload.devices) + : payload?.device ? [[payload.device, payload]] : []; + updates.forEach(([key, telemetry]) => { + if (!powerDeviceByKey.has(key) || !telemetry) return; + powerTelemetryByDevice.set(key, telemetry); + const cardValue = $(`[data-power-device="${key}"] [data-device-power-value]`, domainGrid); + if (cardValue) cardValue.textContent = formatPower(telemetry.power_w ?? telemetry.total_power_w); + }); + if (activePowerDevice && !powerDrawer.hidden) renderPowerDrawer(); + }; + window.addEventListener("cognitive-agent:power-telemetry", (event) => { + updatePowerTelemetry(event.detail || {}); + }); + window.updateDevicePowerTelemetry = updatePowerTelemetry; + + powerEntryDevices.forEach((device) => { + const { card, key, name, measurable } = device; + if (!card) return; + card.classList.add("has-device-power-entry"); + const entry = document.createElement(measurable ? "button" : "div"); + entry.className = `device-power-entry${measurable ? "" : " unavailable"}`; + entry.dataset.powerDevice = key; + if (measurable) { + entry.type = "button"; + entry.setAttribute("aria-label", `查看${name}功率详情`); + entry.innerHTML = ` + 实时功率-- W + 详情`; + entry.addEventListener("click", () => openPowerDrawer(device, entry)); + } else { + entry.setAttribute("aria-label", `${name}暂不支持功率测量`); + entry.innerHTML = ` + 功率测量暂不支持 + 无测量通道`; + } + card.append(entry); + }); + + const cabinCards = $$(":scope > .domain-panel", domainGrid); + let cabinCarouselIndex = 0; + + const cabinCardsPerView = () => 2; + + const scrollToCabinCard = (index, smooth = true) => { + const visibleCount = cabinCardsPerView(); + const maxIndex = Math.max(0, cabinCards.length - visibleCount); + cabinCarouselIndex = Math.max(0, Math.min(index, maxIndex)); + const firstOffset = cabinCards[0]?.offsetLeft || 0; + const targetOffset = cabinCards[cabinCarouselIndex]?.offsetLeft || firstOffset; + domainGrid.scrollTo({ + left: Math.max(0, targetOffset - firstOffset), + behavior: smooth ? "smooth" : "auto", + }); + }; + + const updateCabinCarouselState = () => { + const visibleCount = cabinCardsPerView(); + domainGrid.style.setProperty("--cabin-cards-per-view", String(visibleCount)); + const maxIndex = Math.max(0, cabinCards.length - visibleCount); + const firstCard = cabinCards[0]; + const secondCard = cabinCards[1]; + const cardStep = firstCard && secondCard + ? Math.max(1, secondCard.offsetLeft - firstCard.offsetLeft) + : Math.max(1, firstCard?.offsetWidth || domainGrid.clientWidth); + cabinCarouselIndex = Math.max(0, Math.min(Math.round(domainGrid.scrollLeft / cardStep), maxIndex)); + const range = $("#cabinCarouselRange", cabinToolbar); + if (range) { + range.textContent = `${cabinCarouselIndex + 1}–${Math.min(cabinCards.length, cabinCarouselIndex + visibleCount)}`; + } + const previous = $("#cabinCarouselPrev", cabinToolbar); + const next = $("#cabinCarouselNext", cabinToolbar); + if (previous) previous.disabled = cabinCarouselIndex <= 0; + if (next) next.disabled = cabinCarouselIndex >= maxIndex; + }; + + const moveCabinCarousel = (direction) => { + scrollToCabinCard(cabinCarouselIndex + direction * cabinCardsPerView()); + }; + + const setCabinExpanded = (expanded) => { + shell.classList.toggle("cabin-expanded", expanded); + const button = $("#cabinExpandButton", cabinBand); + if (!button) return; + button.setAttribute("aria-expanded", String(expanded)); + const icon = $("svg", button); + if (icon) icon.outerHTML = ``; + $("span", button).textContent = expanded ? "返回孪生" : "展开查看"; + refreshIcons(); + window.dispatchEvent(new Event("resize")); + requestAnimationFrame(() => { + updateCabinCarouselState(); + scrollToCabinCard(cabinCarouselIndex, false); + }); + }; + + $("#cabinCarouselPrev", cabinToolbar)?.addEventListener("click", () => moveCabinCarousel(-1)); + $("#cabinCarouselNext", cabinToolbar)?.addEventListener("click", () => moveCabinCarousel(1)); + $("#cabinExpandButton", cabinBand)?.addEventListener("click", (event) => { + setCabinExpanded(event.currentTarget.getAttribute("aria-expanded") !== "true"); + }); + window.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + if (!powerDrawer.hidden) { + closePowerDrawer(); + } else if (shell.classList.contains("cabin-expanded")) { + setCabinExpanded(false); + } + }); + + const updateCabinConnection = (status = state.connectionStatus) => { + const indicator = $("#cabinLiveStatus", cabinBand); + const label = $("b", indicator); + if (!indicator || !label) return; + const online = status === "online"; + indicator.dataset.state = online ? "online" : status === "connecting" ? "connecting" : "offline"; + label.textContent = online + ? "通信正常 · 7 个设备在线" + : status === "connecting" ? "正在连接车辆设备" : "车辆设备通信离线"; + }; + window.addEventListener("cognitive-agent:connection", (event) => { + updateCabinConnection(event.detail?.status || "offline"); + }); + updateCabinConnection(); + + domainGrid.addEventListener("scroll", updateCabinCarouselState, { passive: true }); if ("ResizeObserver" in window) { - const domainGridObserver = new ResizeObserver(updateDomainGridScrollState); + const domainGridObserver = new ResizeObserver(() => { + updateCabinCarouselState(); + scrollToCabinCard(cabinCarouselIndex, false); + }); domainGridObserver.observe(domainGrid); } - requestAnimationFrame(updateDomainGridScrollState); + requestAnimationFrame(() => { + updateCabinCarouselState(); + scrollToCabinCard(0, false); + }); coverage.classList.add("joint-hardware-boundary"); shell.append(coverage); stateBand.remove(); @@ -1064,6 +1521,23 @@ function createTwinCabin(THREE, scene) { const TWIN_THEME_PALETTES = { user: { + accent: 0x0e7773, + accentStrong: 0x095d5a, + accentSoft: 0xd8f4f1, + sceneBackground: 0xe8f2ef, + floor: 0xdceae6, + gridStrong: 0x69a49e, + gridSoft: 0xb9cfca, + fillLight: 0x9fd8cf, + vehicleOutline: 0x0e7773, + vehicleOccluded: 0x91b9b4, + seatShell: 0x0e7773, + seatInsert: 0xbfe9e4, + seatThermal: 0x79ccc4, + seatBolster: 0x095d5a, + seatStructure: 0x183f3d, + seatEdge: 0x62d2c8, + seatSeam: 0xd8f4f1, glass: 0x258f89, glassEmissive: 0x124c49, glassEdge: 0x086861, @@ -1075,6 +1549,23 @@ const TWIN_THEME_PALETTES = { lampEdge: 0x096d67, }, admin: { + accent: 0x315eaa, + accentStrong: 0x234785, + accentSoft: 0xe1e9fa, + sceneBackground: 0xe8eef8, + floor: 0xdfe6f3, + gridStrong: 0x7190c2, + gridSoft: 0xbdc9df, + fillLight: 0xa9bfe4, + vehicleOutline: 0x315eaa, + vehicleOccluded: 0x9eafd0, + seatShell: 0x315eaa, + seatInsert: 0xc9d8f2, + seatThermal: 0x91b2e4, + seatBolster: 0x234785, + seatStructure: 0x1d2f52, + seatEdge: 0x88a9df, + seatSeam: 0xe1e9fa, glass: 0x477bc4, glassEmissive: 0x1f427c, glassEdge: 0x234f91, @@ -1088,12 +1579,107 @@ const TWIN_THEME_PALETTES = { }; function currentTwinThemeRole() { + if (document.body.dataset.module === "self-extension") return "user"; return document.body.dataset.demoRole === "admin" || sessionStorage.getItem("cognitive-agent.demo-role") === "admin" ? "admin" : "user"; } +function createTwinGroundTexture(THREE, palette, size = 768) { + const textureCanvas = document.createElement("canvas"); + textureCanvas.width = size; + textureCanvas.height = size; + const context = textureCanvas.getContext("2d"); + if (!context) return null; + + const accent = new THREE.Color(palette.accent); + const gridColor = accent.clone().lerp(new THREE.Color(0xffffff), 0.52); + const rgba = (color, alpha) => { + const red = Math.round(color.r * 255); + const green = Math.round(color.g * 255); + const blue = Math.round(color.b * 255); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + }; + const center = size / 2; + // The white base is fully opaque beneath the vehicle and fades to completely + // transparent at the outer edge. Theme colors are reserved for the grid. + const outerRadius = size * 0.48; + + context.clearRect(0, 0, size, size); + const surfaceGradient = context.createRadialGradient( + center, + center, + 0, + center, + center, + outerRadius, + ); + surfaceGradient.addColorStop(0, "rgba(255, 255, 255, 1)"); + surfaceGradient.addColorStop(0.18, "rgba(255, 255, 255, 1)"); + surfaceGradient.addColorStop(0.48, "rgba(255, 255, 255, 0.72)"); + surfaceGradient.addColorStop(0.75, "rgba(255, 255, 255, 0.28)"); + surfaceGradient.addColorStop(1, "rgba(255, 255, 255, 0)"); + context.fillStyle = surfaceGradient; + context.fillRect(0, 0, size, size); + + const gridCanvas = document.createElement("canvas"); + gridCanvas.width = size; + gridCanvas.height = size; + const gridContext = gridCanvas.getContext("2d"); + const gridStep = size / 40; + for (let index = 0; index <= 40; index += 1) { + const position = index * gridStep; + const major = index % 5 === 0; + gridContext.strokeStyle = rgba(gridColor, major ? 0.32 : 0.16); + gridContext.lineWidth = major ? 0.9 : 0.45; + gridContext.beginPath(); + gridContext.moveTo(position, 0); + gridContext.lineTo(position, size); + gridContext.stroke(); + gridContext.beginPath(); + gridContext.moveTo(0, position); + gridContext.lineTo(size, position); + gridContext.stroke(); + } + const gridMask = gridContext.createRadialGradient( + center, + center, + 0, + center, + center, + outerRadius, + ); + gridMask.addColorStop(0, "rgba(255, 255, 255, 0.64)"); + gridMask.addColorStop(0.22, "rgba(255, 255, 255, 0.56)"); + gridMask.addColorStop(0.52, "rgba(255, 255, 255, 0.36)"); + gridMask.addColorStop(0.8, "rgba(255, 255, 255, 0.1)"); + gridMask.addColorStop(1, "rgba(255, 255, 255, 0)"); + gridContext.globalCompositeOperation = "destination-in"; + gridContext.fillStyle = gridMask; + gridContext.fillRect(0, 0, size, size); + context.drawImage(gridCanvas, 0, 0); + + const texture = new THREE.CanvasTexture(textureCanvas); + if ("colorSpace" in texture) texture.colorSpace = THREE.SRGBColorSpace; + else texture.encoding = THREE.sRGBEncoding; + texture.generateMipmaps = true; + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.needsUpdate = true; + return texture; +} + +function applyTwinGroundTheme(twin, palette) { + if (!twin?.floor?.material || !window.THREE) return; + const nextTexture = createTwinGroundTexture(window.THREE, palette); + const previousTexture = twin.floor.material.map; + twin.floor.material.map = nextTexture; + twin.floor.material.color.setHex(0xffffff); + twin.floor.material.needsUpdate = true; + if (previousTexture && previousTexture !== nextTexture) previousTexture.dispose(); +} + function applyTwinVehicleTheme(systems, role = currentTwinThemeRole()) { if (!systems) return; const themeRole = role === "admin" ? "admin" : "user"; @@ -1134,6 +1720,87 @@ function applyTwinVehicleTheme(systems, role = currentTwinThemeRole()) { }); } +function applyTwinSeatTheme(model, palette) { + if (!model?.materials) return; + const { + shell, + insert, + thermalInsert, + bolster, + structure, + edge, + seam, + } = model.materials; + shell.color.setHex(palette.seatShell); + shell.emissive.setHex(palette.accentStrong); + shell.emissiveIntensity = 0.14; + insert.color.setHex(palette.seatInsert); + insert.emissive.setHex(palette.accentStrong); + insert.emissiveIntensity = 0.1; + thermalInsert.color.setHex(palette.seatThermal); + thermalInsert.emissive.setHex(palette.accentStrong); + thermalInsert.emissiveIntensity = 0.12; + bolster.color.setHex(palette.seatBolster); + bolster.emissive.setHex(palette.accentStrong); + bolster.emissiveIntensity = 0.12; + structure.color.setHex(palette.seatStructure); + structure.emissive.setHex(palette.seatStructure); + structure.emissiveIntensity = 0.08; + edge.color.setHex(palette.seatEdge); + seam.color.setHex(palette.seatSeam); + model.thermalColors.base.setHex(palette.seatThermal); + Object.values(model.angleGuides || {}).forEach((guide) => { + guide.group.traverse((object) => { + if (object.material?.color) object.material.color.setHex(palette.accent); + }); + }); + model.root.traverse((object) => { + if (object.userData.twinOutline?.material?.color) { + object.userData.twinOutline.material.color.setHex(palette.seatEdge); + } + if (object.userData.twinOccludedOutline?.material?.color) { + object.userData.twinOccludedOutline.material.color.setHex(palette.accent); + } + const ghost = object.userData.twinOccludedGhost; + if (ghost) { + const materials = Array.isArray(ghost.material) ? ghost.material : [ghost.material]; + materials.filter(Boolean).forEach((material) => material.color.setHex(palette.accent)); + } + }); +} + +function applyTwinSceneTheme(twin, role = currentTwinThemeRole()) { + if (!twin) return; + const themeRole = document.body.dataset.module === "self-extension" + ? "user" + : role === "admin" ? "admin" : "user"; + const palette = TWIN_THEME_PALETTES[themeRole]; + applyTwinVehicleTheme(twin.vehicleSystems, themeRole); + twin.scene.background.setHex(palette.sceneBackground); + if (twin.scene.fog?.color) twin.scene.fog.color.setHex(palette.sceneBackground); + applyTwinGroundTheme(twin, palette); + if (twin.fillLight?.color) twin.fillLight.color.setHex(palette.fillLight); + Object.values(twin.models || {}).forEach((model) => applyTwinSeatTheme(model, palette)); + [twin.vehicleSystems?.root, twin.referenceVehicle, twin.fixedModels?.rear] + .filter(Boolean) + .forEach((root) => root.traverse((object) => { + if (object.userData.twinOutline?.material?.color) { + object.userData.twinOutline.material.color.setHex(palette.vehicleOutline); + } + if (object.userData.twinOccludedOutline?.material?.color) { + object.userData.twinOccludedOutline.material.color.setHex(palette.vehicleOccluded); + } + })); + // Controllable glass and lamp entities use stronger semantic variants from + // the same role palette after the neutral vehicle outline pass above. + applyTwinVehicleTheme(twin.vehicleSystems, themeRole); + const canvas = twin.renderer?.domElement; + if (canvas) { + canvas.dataset.themeRole = themeRole; + canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`; + } +} + function updateTwinVehicleVisual(systems, timestamp) { if (!systems) return; const ease = (current, target, factor = 0.08) => ( @@ -3099,18 +3766,20 @@ function initSeatTwin() { scene.add(fillLight); const floor = new THREE.Mesh( - new THREE.PlaneGeometry(12, 9), - new THREE.MeshStandardMaterial({ color: 0xdfe7e4, roughness: 0.86 }), + new THREE.PlaneGeometry(30, 30), + new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 1, + depthWrite: false, + side: THREE.DoubleSide, + toneMapped: false, + }), ); floor.rotation.x = -Math.PI / 2; floor.position.y = -0.16; - floor.receiveShadow = true; + floor.renderOrder = 1; scene.add(floor); - const grid = new THREE.GridHelper(10, 20, 0xaebdb9, 0xcbd6d2); - grid.position.y = -0.155; - grid.material.transparent = true; - grid.material.opacity = 0.16; - scene.add(grid); const vehicleSystems = createTwinCabin(THREE, scene); applyTwinOutlineStyle(THREE, vehicleSystems.root, { color: 0x4f747c, @@ -3197,6 +3866,8 @@ function initSeatTwin() { models, fixedModels, vehicleSystems, + floor, + fillLight, selected: "passenger", actuatorState: { passenger: initialDevice(), driver: initialDevice() }, explicitAngleTargets: { passenger: {}, driver: {} }, @@ -3204,9 +3875,12 @@ function initSeatTwin() { dragging: false, pointer: { x: 0, y: 0 }, }; - applyTwinVehicleTheme(vehicleSystems); + applyTwinSceneTheme(state.seatTwin); window.addEventListener("cognitive-agent:demo-role-change", (event) => { - applyTwinVehicleTheme(vehicleSystems, event.detail?.role); + applyTwinSceneTheme(state.seatTwin, event.detail?.role); + }); + window.addEventListener("cognitive-agent:module-change", () => { + applyTwinSceneTheme(state.seatTwin); }); loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) @@ -3215,6 +3889,7 @@ function initSeatTwin() { state.seatTwin.referenceVehicle = vehicle; state.seatTwin.referenceReport = report; state.seatTwin.referenceBounds = normalized.bounds; + applyTwinSceneTheme(state.seatTwin); fixedModels.rear.visible = false; [["passenger", passengerCushion], ["driver", driverCushion]].forEach(([seat, referenceCushion]) => { if (!referenceCushion) return; @@ -4856,9 +5531,11 @@ function renderDomainState(domainState) { rear_right: "rearRightDoorState", }; let knownDoorCount = 0; + let lockedDoorCount = 0; Object.entries(doorIds).forEach(([name, id]) => { const lockState = String(door?.locks?.[name]?.state || "unknown").toLowerCase(); if (lockState !== "unknown") knownDoorCount += 1; + if (lockState === "lock") lockedDoorCount += 1; $(`#${id}`).textContent = doorLabels[lockState] || "未知"; const row = panel?.querySelector(`[data-door-state="${name}"]`); if (row) row.dataset.state = lockState; @@ -4869,6 +5546,22 @@ function renderDomainState(domainState) { $("#vehicleTelemetryState").textContent = hasPhysicalState ? (hasGear && hasSpeed && knownDoorCount === 4 ? "实时" : "部分") : "只读"; + const summaryGear = $("#cabinSummaryGear"); + const summarySpeed = $("#cabinSummarySpeed"); + const summaryDoors = $("#cabinSummaryDoors"); + const summarySystem = $("#cabinSummarySystem"); + if (summaryGear) summaryGear.textContent = hasGear ? `${gear} 挡` : "--"; + if (summarySpeed) summarySpeed.textContent = hasSpeed + ? `${speed < 10 ? speed.toFixed(1) : speed.toFixed(0)} km/h` + : "-- km/h"; + if (summaryDoors) summaryDoors.textContent = knownDoorCount < 4 + ? "状态未知" + : lockedDoorCount === 4 ? "四门已锁" : `${lockedDoorCount} 门已锁`; + if (summarySystem) { + const faulted = Boolean(vehicleStatus?.fault || door?.fault); + summarySystem.textContent = faulted ? "需检查" : "无故障"; + summarySystem.dataset.state = faulted ? "warning" : "healthy"; + } } const ambient = domainState.ambient_light; diff --git a/apps/web-console/console-v2.css b/apps/web-console/console-v2.css index 7d705b6..64dad45 100644 --- a/apps/web-console/console-v2.css +++ b/apps/web-console/console-v2.css @@ -14,6 +14,12 @@ --v2-surface: #ffffff; --v2-canvas: #f4f7f6; --v2-shadow: 0 18px 50px rgba(35, 71, 74, 0.1); + --v2-module-title-block: clamp(12px, 2vh, 22px); + --v2-module-title-inline: clamp(20px, 2.3vw, 34px); + --v2-sidebar-item-height: 52px; + --v2-sidebar-card-height: 43px; + --v2-sidebar-item-radius: 13px; + --v2-sidebar-card-radius: 10px; min-width: 1180px; min-height: 620px; overflow: hidden; @@ -292,7 +298,7 @@ } .v2-control-switch button { - min-height: 52px; + min-height: var(--v2-sidebar-item-height); display: grid; grid-template-columns: 30px 1fr; align-items: center; @@ -316,14 +322,15 @@ .v2-control-switch button:hover { color: var(--v2-accent-strong); - background: rgba(255, 255, 255, 0.5); + background: color-mix(in srgb, var(--v2-accent-soft) 34%, white); + border-color: color-mix(in srgb, var(--v2-accent) 10%, white); } .v2-control-switch button.active { color: #111f20; background: rgba(255, 255, 255, 0.94); - border-color: rgba(255, 255, 255, 0.86); - box-shadow: 0 10px 28px rgba(34, 93, 92, 0.08); + border-color: color-mix(in srgb, var(--v2-accent) 18%, white); + box-shadow: 0 10px 28px color-mix(in srgb, var(--v2-accent) 10%, transparent); } .v2-control-switch button.active svg { @@ -361,7 +368,7 @@ .console-v2 .v2-config-card { min-width: 0; - min-height: 43px; + min-height: var(--v2-sidebar-card-height); display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; @@ -794,7 +801,7 @@ grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(118px, 1fr) auto; gap: 8px; - padding: clamp(18px, 2vh, 24px) clamp(22px, 2.3vw, 34px) 15px; + padding: var(--v2-module-title-block) var(--v2-module-title-inline) 15px; } .v2-operation-heading { @@ -822,6 +829,48 @@ line-height: 1.55; } +/* Shared title system for all five top-level capability modules. */ +.console-v2 .v2-module-heading.v2-module-heading .section-kicker { + display: block; + margin: 0 0 5px; + color: var(--v2-accent-strong); + font: 850 9px/1.2 "SFMono-Regular", Consolas, "Microsoft YaHei", monospace; + letter-spacing: 0.16em; +} + +.console-v2 .v2-module-heading.v2-module-heading h1, +.console-v2 .v2-module-heading.v2-module-heading h2 { + max-width: none; + margin: 0 0 4px; + color: var(--v2-ink); + font-size: clamp(24px, 2vw, 31px); + font-weight: 900; + line-height: 1.08; + letter-spacing: -0.04em; +} + +.console-v2 .v2-module-heading.v2-module-heading > p, +.console-v2 .v2-module-heading.v2-module-heading .panel-intro { + display: block; + max-width: 660px; + margin: 0; + color: var(--v2-muted); + font-size: 11px; + line-height: 1.5; +} + +/* Calibration and repair use their module root as the workspace card. This + matches orchestration, extension and evolution without adding a wrapper or + consuming any additional layout space. */ +.console-v2[data-module="self-calibration"] .v2-self-calibration, +.console-v2[data-module="self-repair"] .v2-self-repair { + box-sizing: border-box; + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(117, 145, 145, 0.14); + border-radius: clamp(17px, 1.4vw, 24px); + box-shadow: var(--v2-shadow); +} + .v2-natural-grid { min-width: 0; min-height: 0; @@ -1255,7 +1304,7 @@ min-width: 0; min-height: 36px; display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); align-items: stretch; margin-inline: calc(0px - var(--v2-main-pad)); padding: 3px var(--v2-main-pad) 0; @@ -1315,6 +1364,544 @@ opacity: 0.78; } +/* Self-evolution journal ------------------------------------------------- */ + +.console-v2 .v2-self-evolution { + display: none; +} + +.console-v2[data-module="self-evolution"] .main-panel { + grid-template-rows: 36px minmax(0, 1fr); +} + +.console-v2[data-module="self-evolution"] .v2-operation-shell, +.console-v2[data-module="self-evolution"] .v2-output-shell { + display: none; +} + +.console-v2[data-module="self-evolution"] .v2-self-evolution { + min-width: 0; + min-height: 0; + display: block; + grid-row: 2; + overflow: auto; + background: + radial-gradient(circle at 10% 0%, color-mix(in srgb, var(--v2-accent-soft) 68%, white), transparent 27%), + var(--v2-surface); + border: 1px solid color-mix(in srgb, var(--v2-accent) 14%, transparent); + border-radius: clamp(17px, 1.4vw, 24px); + box-shadow: var(--v2-shadow); +} + +.v2-evolution-shell { + min-width: 820px; + min-height: 100%; + display: grid; + grid-template-rows: auto minmax(330px, 1fr) auto; +} + +.v2-evolution-overview { + min-width: 0; + display: grid; + grid-template-columns: minmax(300px, 1fr) auto; + align-items: start; + gap: clamp(20px, 3vw, 48px); + padding: var(--v2-module-title-block) var(--v2-module-title-inline); + border-bottom: 1px solid var(--v2-line); +} + +.v2-evolution-heading .section-kicker { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--v2-accent-strong); + font-size: 9px; + font-weight: 850; + letter-spacing: 0.18em; +} + +.v2-evolution-heading .section-kicker svg { + width: 15px; + height: 15px; +} + +.v2-evolution-heading h1 { + margin: 8px 0 0; + color: var(--v2-ink); + font-size: clamp(24px, 2vw, 34px); + line-height: 1.06; + letter-spacing: -0.04em; +} + +.v2-evolution-heading > p { + margin: 9px 0 0; + color: var(--v2-muted); + font-size: 12px; + line-height: 1.6; +} + +.v2-evolution-summary { + min-width: 430px; + display: grid; + grid-template-columns: 1.25fr repeat(3, 1fr); + margin: 0; + overflow: hidden; + background: color-mix(in srgb, var(--v2-accent-soft) 38%, white); + border: 1px solid color-mix(in srgb, var(--v2-accent) 13%, transparent); + border-radius: 14px; +} + +.v2-evolution-summary > div { + min-width: 0; + display: grid; + align-content: center; + padding: 11px 14px; + border-left: 1px solid var(--v2-line); +} + +.v2-evolution-summary > div:first-child { + border-left: 0; +} + +.v2-evolution-summary dt, +.v2-evolution-summary small { + color: var(--v2-muted); + font-size: 8px; + font-weight: 750; +} + +.v2-evolution-summary dd { + margin: 2px 0 0; + color: var(--v2-ink); + font: 850 19px/1.1 "SFMono-Regular", Consolas, "Microsoft YaHei", monospace; +} + +.v2-evolution-summary .v2-evolution-current dd { + color: var(--v2-accent-strong); +} + +.v2-evolution-workspace { + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: clamp(225px, 21vw, 290px) minmax(0, 1fr); +} + +.v2-evolution-timeline { + min-width: 0; + padding: 20px 18px 18px 24px; + background: color-mix(in srgb, var(--v2-accent-soft) 13%, white); + border-right: 1px solid var(--v2-line); +} + +.v2-evolution-timeline-heading { + padding: 0 8px 12px 15px; +} + +.v2-evolution-timeline-heading span, +.v2-evolution-next small { + display: block; + color: var(--v2-accent-strong); + font-size: 8px; + font-weight: 850; + letter-spacing: 0.16em; +} + +.v2-evolution-timeline-heading strong { + display: block; + margin-top: 4px; + color: var(--v2-ink); + font-size: 13px; +} + +.v2-evolution-version-list { + position: relative; + display: grid; + gap: 4px; +} + +.v2-evolution-version-list::before { + width: 1px; + position: absolute; + inset: 11px auto 11px 6px; + content: ""; + background: color-mix(in srgb, var(--v2-accent) 24%, transparent); +} + +.v2-evolution-version { + min-width: 0; + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 10px; + padding: 8px 9px 8px 25px; + color: var(--v2-muted); + text-align: left; + background: transparent; + border: 0; + border-radius: var(--v2-sidebar-card-radius); + cursor: pointer; +} + +.v2-evolution-version::before { + width: 7px; + height: 7px; + position: absolute; + top: 14px; + left: 3px; + z-index: 1; + content: ""; + background: white; + border: 2px solid color-mix(in srgb, var(--v2-accent) 42%, #a8b4b4); + border-radius: 50%; +} + +.v2-evolution-version:hover { + color: var(--v2-ink); + background: color-mix(in srgb, var(--v2-accent-soft) 38%, transparent); +} + +.v2-evolution-version.active { + color: var(--v2-accent-strong); + background: color-mix(in srgb, var(--v2-accent-soft) 72%, white); +} + +.v2-evolution-version.active::before { + background: var(--v2-accent); + border-color: white; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--v2-accent) 14%, transparent); +} + +.v2-evolution-version > span { + display: flex; + align-items: center; + gap: 8px; +} + +.v2-evolution-version strong { + color: currentColor; + font: 850 12px/1.2 "SFMono-Regular", Consolas, monospace; +} + +.v2-evolution-version time, +.v2-evolution-version small { + color: var(--v2-muted); + font-size: 8px; +} + +.v2-evolution-version b { + grid-column: 1 / -1; + overflow: hidden; + color: var(--v2-ink); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-evolution-version small { + grid-column: 2; + grid-row: 1; + align-self: center; + white-space: nowrap; +} + +.v2-evolution-detail { + min-width: 0; + padding: clamp(20px, 2.2vw, 32px) clamp(24px, 2.7vw, 42px) 20px; +} + +.v2-evolution-detail-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.v2-evolution-detail-heading > div { + display: flex; + align-items: center; + gap: 10px; +} + +.v2-evolution-status { + padding: 4px 9px; + color: var(--v2-accent-strong); + background: var(--v2-accent-soft); + border-radius: 999px; + font-size: 9px; + font-weight: 850; +} + +.v2-evolution-detail-version { + color: var(--v2-muted); + font: 800 11px/1 "SFMono-Regular", Consolas, monospace; +} + +.v2-evolution-detail-heading time { + color: var(--v2-muted); + font: 750 10px/1 "SFMono-Regular", Consolas, monospace; +} + +.v2-evolution-detail > h2 { + margin: 12px 0 0; + color: var(--v2-ink); + font-size: clamp(20px, 1.7vw, 28px); + line-height: 1.15; + letter-spacing: -0.035em; +} + +.v2-evolution-lead { + max-width: 860px; + margin: 7px 0 0; + color: var(--v2-muted); + font-size: 11px; + line-height: 1.6; +} + +.v2-evolution-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(240px, 0.92fr); + gap: clamp(24px, 3vw, 48px); + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--v2-line); +} + +.v2-evolution-detail-grid h3 { + display: flex; + align-items: center; + gap: 7px; + margin: 0 0 12px; + color: var(--v2-ink); + font-size: 11px; +} + +.v2-evolution-detail-grid h3 svg { + width: 14px; + height: 14px; + color: var(--v2-accent); +} + +.v2-evolution-changes > div { + display: grid; + gap: 10px; +} + +.v2-evolution-changes > div > div { + min-width: 0; + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + gap: 9px; +} + +.v2-evolution-changes > div > div > svg { + width: 22px; + height: 22px; + padding: 5px; + color: var(--v2-accent-strong); + background: var(--v2-accent-soft); + border-radius: 50%; +} + +.v2-evolution-changes p { + margin: 0; +} + +.v2-evolution-changes p strong, +.v2-evolution-changes p span { + display: block; +} + +.v2-evolution-changes p strong { + color: var(--v2-ink); + font-size: 10px; +} + +.v2-evolution-changes p span { + margin-top: 2px; + color: var(--v2-muted); + font-size: 9px; + line-height: 1.45; +} + +.v2-evolution-evidence > div { + display: grid; + gap: 12px; +} + +.v2-evolution-metric > div { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.v2-evolution-metric > div > span { + overflow: hidden; + color: var(--v2-ink); + font-size: 9px; + font-weight: 750; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-evolution-metric p { + display: flex; + align-items: center; + gap: 5px; + margin: 0; + white-space: nowrap; +} + +.v2-evolution-metric del { + color: #98a5a6; + font-size: 8px; + text-decoration-thickness: 1px; +} + +.v2-evolution-metric p svg { + width: 10px; + height: 10px; + color: var(--v2-muted); +} + +.v2-evolution-metric p strong { + color: var(--v2-accent-strong); + font-size: 10px; +} + +.v2-evolution-progress { + width: 100%; + height: 5px; + display: block; + margin-top: 5px; + overflow: hidden; + background: color-mix(in srgb, var(--v2-accent-soft) 50%, #e7eceb); + border-radius: 999px; +} + +.v2-evolution-progress i { + width: var(--evolution-progress); + height: 100%; + display: block; + background: linear-gradient(90deg, color-mix(in srgb, var(--v2-accent) 55%, white), var(--v2-accent)); + border-radius: inherit; + transition: width 220ms ease; +} + +.v2-evolution-validation { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 14px; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--v2-line); +} + +.v2-evolution-module-tags { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.v2-evolution-module-tags span, +.v2-evolution-next-steps span { + padding: 4px 8px; + color: var(--v2-accent-strong); + background: color-mix(in srgb, var(--v2-accent-soft) 62%, white); + border: 1px solid color-mix(in srgb, var(--v2-accent) 12%, transparent); + border-radius: 999px; + font-size: 8px; + font-weight: 800; + white-space: nowrap; +} + +.v2-evolution-validation > p { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 7px; + margin: 0; + color: var(--v2-muted); + font-size: 9px; + text-align: right; +} + +.v2-evolution-validation > p svg { + width: 14px; + height: 14px; + flex: 0 0 auto; + color: var(--v2-accent); +} + +.console-v2 [data-evolution-scope="admin"] { + display: none; +} + +.console-v2[data-demo-role="admin"] [data-evolution-scope="user"] { + display: none; +} + +.console-v2[data-demo-role="admin"] [data-evolution-scope="admin"] { + display: inline; +} + +.v2-evolution-next { + min-width: 0; + display: grid; + grid-template-columns: 38px minmax(280px, 1fr) auto; + align-items: center; + gap: 13px; + padding: 13px clamp(22px, 2.2vw, 34px); + background: color-mix(in srgb, var(--v2-accent-soft) 58%, white); + border-top: 1px solid color-mix(in srgb, var(--v2-accent) 15%, transparent); +} + +.v2-evolution-next-icon { + width: 36px; + height: 36px; + display: grid; + place-items: center; + color: white; + background: var(--v2-accent); + border-radius: 11px; + box-shadow: 0 7px 18px color-mix(in srgb, var(--v2-accent) 22%, transparent); +} + +.v2-evolution-next-icon svg { + width: 16px; + height: 16px; +} + +.v2-evolution-next strong { + display: inline-block; + margin-top: 2px; + color: var(--v2-ink); + font-size: 12px; +} + +.v2-evolution-next p { + display: inline; + margin: 0 0 0 8px; + color: var(--v2-muted); + font-size: 9px; +} + +.v2-evolution-next-steps { + display: flex; + align-items: center; + gap: 6px; +} + +.v2-evolution-next-steps svg { + width: 12px; + height: 12px; + color: var(--v2-accent); +} + .console-v2 .v2-inline-gesture .v2-camera-toggle { width: 42px; height: 42px; @@ -1893,13 +2480,13 @@ display: grid; margin: 0; overflow: hidden; - background: rgba(255, 255, 255, 0.72); - border: 1px solid rgba(113, 141, 140, 0.14); + background: color-mix(in srgb, var(--v2-accent-soft) 8%, white); + border: 1px solid color-mix(in srgb, var(--v2-accent) 14%, transparent); border-radius: 12px; } .console-v2 .joint-twin-shell > .twin-band { - grid-template-rows: 38px minmax(0, 1fr); + grid-template-rows: 62px minmax(0, 1fr); } .console-v2 .joint-twin-shell > .cabin-band { @@ -1910,20 +2497,33 @@ .console-v2 .joint-twin-shell .twin-heading { min-height: 0; - padding: 4px 8px 4px 10px; - background: rgba(255, 255, 255, 0.6); - border-bottom: 1px solid rgba(113, 141, 140, 0.12); + padding: 9px 12px; + background: + linear-gradient( + 105deg, + color-mix(in srgb, var(--v2-accent-soft) 86%, white), + color-mix(in srgb, var(--v2-accent-soft) 18%, white) 58% + ), + white; + border-bottom: 1px solid color-mix(in srgb, var(--v2-accent) 14%, transparent); } .console-v2 .joint-twin-shell .twin-heading .section-kicker { display: block; - font-size: 6px; + color: var(--v2-accent-strong); + font-size: 7px; + letter-spacing: 0.12em; } .console-v2 .joint-twin-shell .twin-heading h3 { display: block; - margin: 0; - font-size: 13px; + margin: 2px 0 0; + color: var(--v2-ink); + font-size: 16px; +} + +.console-v2 .joint-twin-shell .twin-title-copy { + min-width: 0; } .console-v2 .joint-twin-shell .twin-toolbar { @@ -1974,9 +2574,13 @@ margin: 0; padding: 8px 10px; background: - linear-gradient(105deg, rgba(223, 245, 241, 0.82), rgba(255, 255, 255, 0.74) 58%), - rgba(255, 255, 255, 0.62); - border-bottom: 1px solid rgba(113, 141, 140, 0.12); + linear-gradient( + 105deg, + color-mix(in srgb, var(--v2-accent-soft) 86%, white), + color-mix(in srgb, var(--v2-accent-soft) 18%, white) 58% + ), + white; + border-bottom: 1px solid color-mix(in srgb, var(--v2-accent) 14%, transparent); } .console-v2 .joint-twin-shell .cabin-heading .section-kicker { @@ -1988,6 +2592,7 @@ .console-v2 .joint-twin-shell .cabin-heading h3 { margin: 1px 0 0; + color: var(--v2-ink); font-size: 13px; } @@ -1999,7 +2604,7 @@ max-width: 420px; margin: 2px 0 0; overflow: hidden; - color: #6b7e7e; + color: var(--v2-muted); font-size: 7px; line-height: 1.35; text-overflow: ellipsis; @@ -2019,9 +2624,9 @@ align-items: center; gap: 4px; padding: 4px 6px; - color: #52696a; - background: rgba(255, 255, 255, 0.76); - border: 1px solid rgba(73, 122, 119, 0.13); + color: color-mix(in srgb, var(--v2-ink) 74%, var(--v2-muted)); + background: color-mix(in srgb, var(--v2-accent-soft) 24%, white); + border: 1px solid color-mix(in srgb, var(--v2-accent) 14%, transparent); border-radius: 999px; font-size: 7px; font-weight: 800; @@ -2037,10 +2642,6 @@ font-style: normal; } -.console-v2 .joint-twin-shell .cabin-channel-legend [data-channel="ch0"] i { - background: #3677b5; -} - .console-v2 .joint-twin-shell .cabin-heading .simulation-label { display: none; } @@ -2057,7 +2658,7 @@ overflow-x: hidden; overflow-y: auto; scrollbar-width: thin; - scrollbar-color: rgba(28, 111, 104, 0.28) transparent; + scrollbar-color: color-mix(in srgb, var(--v2-accent) 34%, transparent) transparent; } .console-v2 .joint-twin-shell .domain-panel { @@ -2084,12 +2685,12 @@ } .console-v2 .joint-twin-shell .domain-panel[data-device-channel="ch0"]::before { - background: #3677b5; + background: var(--v2-accent); } .console-v2 .joint-twin-shell .domain-panel.active { border-color: color-mix(in srgb, var(--v2-accent) 32%, #dce7e4); - box-shadow: 0 7px 18px rgba(19, 112, 104, 0.08); + box-shadow: 0 7px 18px color-mix(in srgb, var(--v2-accent) 9%, transparent); } .console-v2 .joint-twin-shell .domain-panel header { @@ -2512,13 +3113,22 @@ margin-top: 14px; } + .console-v2[data-module="self-calibration"] .v2-calibration-sidebar, + .console-v2[data-module="self-extension"] .v2-extension-sidebar, + .console-v2[data-module="self-repair"] .v2-repair-sidebar { + margin-top: 14px; + } + .v2-section-heading h2 { margin: 0 0 10px; font-size: 22px; } .v2-control-section > .v2-field-caption, - .v2-config-section > .v2-field-caption { + .v2-config-section > .v2-field-caption, + .v2-calibration-sidebar > .v2-field-caption, + .v2-extension-sidebar > .v2-field-caption, + .v2-repair-sidebar > .v2-field-caption { margin-bottom: 5px; font-size: 11px; } @@ -2533,13 +3143,37 @@ grid-template-columns: 1fr; } - .v2-control-switch button { + .v2-control-switch button, + .v2-calibration-mode-switch button, + .v2-extension-mode-switch button, + .v2-repair-view-switch button { min-height: 41px; grid-template-columns: 21px minmax(0, 1fr); padding: 0 9px; font-size: 10px; } + .v2-control-switch button svg, + .v2-calibration-mode-switch button svg, + .v2-extension-mode-switch button svg, + .v2-repair-view-switch button > svg { + width: 17px; + height: 17px; + } + + .v2-repair-view-switch button { + min-height: 48px; + padding-block: 4px; + } + + .v2-repair-view-switch strong { + font-size: 10px; + } + + .v2-repair-view-switch small { + font-size: 8px; + } + .v2-config-section { margin-top: 12px; } @@ -2553,7 +3187,11 @@ padding-left: 8px; } - .console-v2 .v2-config-card { + .console-v2 .v2-config-card, + .v2-calibration-config-card, + .v2-extension-config-card, + .v2-repair-config-card, + .v2-extension-metrics > div { min-height: 38px; } @@ -2563,7 +3201,7 @@ .console-v2 .main-panel { --v2-main-pad: 9px; - grid-template-rows: 34px clamp(207px, 29dvh, 236px) minmax(0, 1fr); + grid-template-rows: 36px clamp(236px, 33dvh, 246px) minmax(0, 1fr); gap: 12px; padding: 0 var(--v2-main-pad) var(--v2-main-pad); } @@ -2572,7 +3210,7 @@ grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(102px, 1fr) auto; gap: 5px; - padding: 12px 20px 8px; + padding: var(--v2-module-title-block) var(--v2-module-title-inline) 8px; } .console-v2 .v2-gesture-panel { @@ -2730,9 +3368,9 @@ } } -/* Whole-vehicle state board: eight balanced cards in a complete 4 x 2 matrix. */ +/* Keep the digital twin and device state board equally weighted. */ .console-v2 .joint-twin-shell.v2-output-page { - grid-template-columns: minmax(360px, 0.72fr) minmax(0, 1.28fr); + grid-template-columns: repeat(2, minmax(0, 1fr)); } .console-v2 .joint-twin-shell .twin-heading h3, @@ -3386,6 +4024,362 @@ white-space: nowrap; } +/* Compact vehicle state board ------------------------------------------- */ +.console-v2 .joint-twin-shell > .cabin-band.cabin-compact-board { + grid-template-rows: 62px 80px minmax(0, 1fr); + background: color-mix(in srgb, var(--v2-accent-soft) 7%, white); +} + +.console-v2 .cabin-compact-board .cabin-heading { + padding: 9px 12px; +} + +.console-v2 .cabin-compact-board .cabin-title-copy p, +.console-v2 .cabin-compact-board .cabin-channel-legend { + display: none; +} + +.console-v2 .cabin-compact-board .cabin-heading .section-kicker { + font-size: 7px; +} + +.console-v2 .cabin-compact-board .cabin-heading h3 { + margin-top: 2px; + font-size: 16px; +} + +.console-v2 .cabin-heading-actions { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; +} + +.console-v2 .cabin-live-status { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--v2-accent-strong); + font-size: 12px; + white-space: nowrap; +} + +.console-v2 .cabin-live-status > i { + width: 7px; + height: 7px; + flex: 0 0 7px; + background: var(--v2-accent); + border-radius: 50%; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--v2-accent) 12%, transparent); +} + +.console-v2 .cabin-live-status[data-state="connecting"] { + color: var(--v2-muted); +} + +.console-v2 .cabin-live-status[data-state="connecting"] > i { + background: #d19836; + box-shadow: 0 0 0 4px rgba(209, 152, 54, 0.12); +} + +.console-v2 .cabin-live-status[data-state="offline"] { + color: #a06455; +} + +.console-v2 .cabin-live-status[data-state="offline"] > i { + background: #c67a68; + box-shadow: 0 0 0 4px rgba(198, 122, 104, 0.12); +} + +.console-v2 .cabin-expand-button { + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 11px; + color: var(--v2-accent-strong); + background: rgba(255, 255, 255, 0.82); + border: 1px solid color-mix(in srgb, var(--v2-accent) 27%, transparent); + border-radius: 9px; + font: 750 12px/1 Inter, "PingFang SC", "Microsoft YaHei", sans-serif; + cursor: pointer; + transition: 160ms ease; +} + +.console-v2 .cabin-expand-button:hover { + background: color-mix(in srgb, var(--v2-accent-soft) 55%, white); + border-color: color-mix(in srgb, var(--v2-accent) 45%, transparent); +} + +.console-v2 .cabin-expand-button svg { + width: 14px; + height: 14px; +} + +.console-v2 .cabin-board-toolbar { + min-width: 0; + display: grid; + grid-template-rows: 40px 32px; + gap: 4px; + padding: 8px 10px 0; + background: rgba(255, 255, 255, 0.78); + border-bottom: 1px solid var(--v2-line); +} + +.console-v2 .cabin-vehicle-summary { + min-width: 0; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0; + overflow: hidden; + background: color-mix(in srgb, var(--v2-accent-soft) 24%, #f3f6f5); + border-radius: 8px; +} + +.console-v2 .cabin-vehicle-summary > div { + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 10px; + border-left: 1px solid var(--v2-line); +} + +.console-v2 .cabin-vehicle-summary > div:first-child { + border-left: 0; +} + +.console-v2 .cabin-vehicle-summary dt { + overflow: hidden; + color: var(--v2-muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2 .cabin-vehicle-summary dd { + margin: 0; + overflow: hidden; + color: var(--v2-ink); + font-size: 13px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2 .cabin-vehicle-summary dd[data-state="healthy"] { + color: var(--v2-accent-strong); +} + +.console-v2 .cabin-vehicle-summary dd[data-state="warning"] { + color: #a36a1b; +} + +.console-v2 .cabin-carousel-controls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.console-v2 .cabin-carousel-count { + display: inline-flex; + align-items: baseline; + color: var(--v2-muted); + font-size: 11px; + font-style: normal; +} + +.console-v2 .cabin-carousel-count b { + color: var(--v2-accent-strong); + font-size: 12px; +} + +.console-v2 .cabin-carousel-count i { + margin-left: 3px; + font-style: normal; +} + +.console-v2 .cabin-carousel-controls > div { + display: inline-flex; + overflow: hidden; + background: rgba(255, 255, 255, 0.82); + border: 1px solid color-mix(in srgb, var(--v2-accent) 22%, var(--v2-line)); + border-radius: 9px; +} + +.console-v2 .cabin-carousel-controls button { + width: 36px; + min-height: 29px; + display: inline-grid; + place-items: center; + padding: 0; + color: var(--v2-accent-strong); + background: transparent; + border: 0; + border-left: 1px solid var(--v2-line); + cursor: pointer; + transition: 160ms ease; +} + +.console-v2 .cabin-carousel-controls button:first-child { + border-left: 0; +} + +.console-v2 .cabin-carousel-controls button:hover:not(:disabled) { + background: color-mix(in srgb, var(--v2-accent-soft) 72%, white); +} + +.console-v2 .cabin-carousel-controls button:disabled { + color: color-mix(in srgb, var(--v2-muted) 48%, white); + cursor: default; +} + +.console-v2 .cabin-carousel-controls button svg { + width: 16px; + height: 16px; +} + +.console-v2 .cabin-compact-board .domain-grid { + --cabin-cards-per-view: 2; + --cabin-card-gap: 10px; + min-width: 0; + display: flex; + align-items: stretch; + gap: var(--cabin-card-gap); + margin-inline: 10px; + padding: 10px 0; + overflow-x: auto; + overflow-y: hidden; + scroll-behavior: smooth; + scroll-snap-type: x mandatory; + scrollbar-width: none; + overscroll-behavior-inline: contain; + touch-action: pan-x; +} + +.console-v2 .cabin-compact-board .domain-grid::-webkit-scrollbar { + display: none; +} + +.console-v2 .cabin-compact-board .domain-grid > .domain-panel { + min-width: 0; + flex: 0 0 calc((100% - (var(--cabin-cards-per-view) - 1) * var(--cabin-card-gap)) / var(--cabin-cards-per-view)); + scroll-snap-align: start; +} + +.console-v2 .cabin-compact-board .domain-panel { + height: 100%; + padding: 13px 14px 52px; + background: rgba(255, 255, 255, 0.96); + border-color: color-mix(in srgb, var(--v2-accent) 13%, #dfe8e6); + border-radius: 11px; + box-shadow: 0 3px 10px rgba(31, 66, 67, 0.035); +} + +.console-v2 .cabin-compact-board .device-power-entry { + min-width: 0; + height: 36px; + position: absolute; + inset: auto 14px 10px; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 10px; + color: var(--v2-ink); + background: color-mix(in srgb, var(--v2-accent-soft) 34%, #f7f9f8); + border: 1px solid color-mix(in srgb, var(--v2-accent) 15%, var(--v2-line)); + border-radius: 8px; + font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; + text-align: left; +} + +.console-v2 .cabin-compact-board button.device-power-entry { + cursor: pointer; + transition: 160ms ease; +} + +.console-v2 .cabin-compact-board button.device-power-entry:hover { + background: color-mix(in srgb, var(--v2-accent-soft) 65%, white); + border-color: color-mix(in srgb, var(--v2-accent) 36%, var(--v2-line)); + box-shadow: 0 3px 10px color-mix(in srgb, var(--v2-accent) 9%, transparent); +} + +.console-v2 .cabin-compact-board button.device-power-entry:focus-visible { + outline: 2px solid color-mix(in srgb, var(--v2-accent) 48%, transparent); + outline-offset: 2px; +} + +.console-v2 .cabin-compact-board .device-power-copy { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 7px; +} + +.console-v2 .cabin-compact-board .device-power-copy > svg { + width: 15px; + height: 15px; + flex: 0 0 15px; + color: var(--v2-accent); +} + +.console-v2 .cabin-compact-board .device-power-copy > span { + min-width: 0; + display: flex; + align-items: baseline; + gap: 7px; +} + +.console-v2 .cabin-compact-board .device-power-copy small { + color: var(--v2-muted); + font-size: 10px; + white-space: nowrap; +} + +.console-v2 .cabin-compact-board .device-power-copy b { + color: var(--v2-accent-strong); + font: 850 13px/1 "SFMono-Regular", Consolas, "Microsoft YaHei", monospace; + white-space: nowrap; +} + +.console-v2 .cabin-compact-board .device-power-action { + display: inline-flex; + align-items: center; + gap: 2px; + color: var(--v2-accent-strong); + font-size: 10px; + font-weight: 750; + white-space: nowrap; +} + +.console-v2 .cabin-compact-board .device-power-action svg { + width: 13px; + height: 13px; +} + +.console-v2 .cabin-compact-board .device-power-entry.unavailable { + color: var(--v2-muted); + background: #f4f6f5; + border-color: var(--v2-line); +} + +.console-v2 .cabin-compact-board .device-power-entry.unavailable .device-power-copy > svg, +.console-v2 .cabin-compact-board .device-power-entry.unavailable .device-power-copy b { + color: color-mix(in srgb, var(--v2-muted) 78%, white); +} + +.console-v2 .cabin-compact-board .device-power-unavailable { + overflow: hidden; + color: color-mix(in srgb, var(--v2-muted) 78%, white); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + .console-v2 .v2-reasoning-field .reasoning-option > span > strong { color: inherit; font-size: 10px; @@ -3484,8 +4478,798 @@ white-space: nowrap; } +body.device-power-drawer-open { + overflow: hidden; +} + +.console-v2 .device-power-backdrop { + position: fixed; + inset: 0; + z-index: 1200; + background: rgba(21, 42, 45, 0.32); + backdrop-filter: blur(1.5px); +} + +.console-v2 .device-power-drawer { + width: min(470px, calc(100vw - 24px)); + position: fixed; + inset: 10px 10px 10px auto; + z-index: 1210; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + color: var(--v2-ink); + background: rgba(255, 255, 255, 0.985); + border: 1px solid color-mix(in srgb, var(--v2-accent) 17%, var(--v2-line)); + border-radius: 16px; + box-shadow: -18px 0 48px rgba(24, 55, 58, 0.17); +} + +.console-v2 .device-power-backdrop[hidden], +.console-v2 .device-power-drawer[hidden] { + display: none !important; +} + +.console-v2 .device-power-drawer-heading { + min-width: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 18px 13px; + border-bottom: 1px solid var(--v2-line); +} + +.console-v2 .device-power-drawer-heading .section-kicker { + color: var(--v2-accent-strong); + font-size: 8px; +} + +.console-v2 .device-power-drawer-heading h2 { + margin: 4px 0 5px; + color: var(--v2-ink); + font-size: 20px; + line-height: 1.2; +} + +.console-v2 .device-power-drawer-heading p { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + color: var(--v2-muted); + font-size: 10px; +} + +.console-v2 .device-power-drawer-heading p i { + width: 6px; + height: 6px; + flex: 0 0 6px; + background: var(--v2-accent); + border-radius: 50%; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--v2-accent) 10%, transparent); +} + +.console-v2 .device-power-drawer-heading > button { + width: 32px; + height: 32px; + flex: 0 0 32px; + display: inline-grid; + place-items: center; + padding: 0; + color: var(--v2-muted); + background: #f5f8f7; + border: 1px solid var(--v2-line); + border-radius: 9px; + cursor: pointer; +} + +.console-v2 .device-power-drawer-heading > button:hover { + color: var(--v2-accent-strong); + background: var(--v2-accent-soft); +} + +.console-v2 .device-power-drawer-heading > button svg { + width: 15px; + height: 15px; +} + +.console-v2 .device-power-drawer-body { + min-height: 0; + display: grid; + align-content: start; + gap: 16px; + padding: 16px 18px 18px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--v2-accent) 28%, transparent) transparent; +} + +.console-v2 .device-power-overview { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(126px, 0.9fr); + gap: 10px; +} + +.console-v2 .device-power-total { + min-height: 116px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 16px 18px; + color: #fff; + background: linear-gradient(135deg, var(--v2-accent), color-mix(in srgb, var(--v2-accent) 78%, #25aaa2)); + border-radius: 13px; + box-shadow: 0 8px 20px color-mix(in srgb, var(--v2-accent) 17%, transparent); +} + +.console-v2 .device-power-total small { + font-size: 11px; + font-weight: 650; + opacity: 0.86; +} + +.console-v2 .device-power-total strong { + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 6px; + font-family: "SFMono-Regular", Consolas, "Microsoft YaHei", monospace; +} + +.console-v2 .device-power-total strong b { + font-size: 31px; + line-height: 1; +} + +.console-v2 .device-power-total strong span { + font-size: 14px; +} + +.console-v2 .device-power-total em { + margin-top: 10px; + font-size: 9px; + font-style: normal; + opacity: 0.72; +} + +.console-v2 .device-power-energy-summary { + min-width: 0; + display: grid; + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 0; +} + +.console-v2 .device-power-energy-summary > div { + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: 10px 12px; + background: #f6f9f8; + border: 1px solid var(--v2-line); + border-radius: 10px; +} + +.console-v2 .device-power-energy-summary dt { + color: var(--v2-muted); + font-size: 10px; +} + +.console-v2 .device-power-energy-summary dd { + margin: 4px 0 0; + overflow: hidden; + color: var(--v2-ink); + font: 850 15px/1.15 "SFMono-Regular", Consolas, "Microsoft YaHei", monospace; + text-overflow: ellipsis; + white-space: nowrap; +} + @media (max-height: 820px) { .console-v2 .v2-reasoning-trigger { bottom: 7px; } } + +.console-v2 .device-power-components, +.console-v2 .device-power-trend { + min-width: 0; +} + +.console-v2 .device-power-components > header, +.console-v2 .device-power-trend > header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.console-v2 .device-power-components h3, +.console-v2 .device-power-trend h3 { + margin: 2px 0 0; + color: var(--v2-ink); + font-size: 14px; +} + +.console-v2 .device-power-components .section-kicker, +.console-v2 .device-power-trend .section-kicker { + color: var(--v2-accent-strong); + font-size: 7px; +} + +.console-v2 #devicePowerComponentTotal { + color: var(--v2-muted); + font-size: 10px; + white-space: nowrap; +} + +.console-v2 .device-power-component-list { + overflow: hidden; + background: #fff; + border: 1px solid var(--v2-line); + border-radius: 11px; +} + +.console-v2 .device-power-component-row { + min-width: 0; + min-height: 42px; + display: grid; + grid-template-columns: minmax(106px, 0.9fr) minmax(90px, 1.4fr) minmax(62px, auto); + align-items: center; + gap: 10px; + padding: 7px 12px; + border-top: 1px solid var(--v2-line); +} + +.console-v2 .device-power-component-row:first-child { + border-top: 0; +} + +.console-v2 .device-power-component-row > span { + overflow: hidden; + color: var(--v2-ink); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2 .device-power-component-row > i { + height: 5px; + position: relative; + overflow: hidden; + background: #e7efed; + border-radius: 999px; +} + +.console-v2 .device-power-component-row > i b { + width: var(--power-share, 0%); + position: absolute; + inset: 0 auto 0 0; + background: var(--v2-accent); + border-radius: inherit; + transition: width 220ms ease; +} + +.console-v2 .device-power-component-row > strong { + color: var(--v2-ink); + font: 850 11px/1 "SFMono-Regular", Consolas, "Microsoft YaHei", monospace; + text-align: right; + white-space: nowrap; +} + +.console-v2 .device-power-components-empty { + min-height: 108px; + display: flex; + align-items: center; + gap: 13px; + padding: 18px; + background: color-mix(in srgb, var(--v2-accent-soft) 18%, #f7f9f8); +} + +.console-v2 .device-power-components-empty > span { + width: 40px; + height: 40px; + flex: 0 0 40px; + display: grid; + place-items: center; + color: var(--v2-accent-strong); + background: color-mix(in srgb, var(--v2-accent-soft) 68%, white); + border-radius: 10px; +} + +.console-v2 .device-power-components-empty svg { + width: 18px; + height: 18px; +} + +.console-v2 .device-power-components-empty strong { + font-size: 13px; +} + +.console-v2 .device-power-components-empty p { + margin: 5px 0 0; + color: var(--v2-muted); + font-size: 10px; + line-height: 1.5; +} + +.console-v2 .device-power-trend-tabs { + display: inline-flex; + padding: 2px; + background: #f3f6f5; + border: 1px solid var(--v2-line); + border-radius: 9px; +} + +.console-v2 .device-power-trend-tabs button { + min-height: 24px; + padding: 0 8px; + color: var(--v2-muted); + background: transparent; + border: 0; + border-radius: 6px; + font: 650 9px/1 Inter, "PingFang SC", "Microsoft YaHei", sans-serif; + cursor: pointer; +} + +.console-v2 .device-power-trend-tabs button.active { + color: var(--v2-accent-strong); + background: #fff; + box-shadow: 0 1px 5px rgba(34, 70, 72, 0.08); +} + +.console-v2 .device-power-chart { + height: 190px; + position: relative; + padding: 8px 8px 24px; + overflow: hidden; + background: linear-gradient(180deg, color-mix(in srgb, var(--v2-accent-soft) 12%, white), #fff); + border: 1px solid var(--v2-line); + border-radius: 11px; +} + +.console-v2 .device-power-chart svg { + width: 100%; + height: 154px; + display: block; +} + +.console-v2 .device-power-chart-grid line { + stroke: #e5ecea; + stroke-width: 1; +} + +.console-v2 .device-power-chart-area { + fill: color-mix(in srgb, var(--v2-accent) 12%, transparent); +} + +.console-v2 .device-power-chart-line { + fill: none; + stroke: var(--v2-accent); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.console-v2 .device-power-chart-y { + position: absolute; + left: 10px; + color: var(--v2-muted); + font-size: 8px; +} + +.console-v2 .device-power-chart-y.top { + top: 15px; +} + +.console-v2 .device-power-chart-y.bottom { + bottom: 25px; +} + +.console-v2 .device-power-chart-empty { + position: absolute; + inset: 36px 18px 37px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + color: var(--v2-muted); + background: rgba(255, 255, 255, 0.74); + border: 1px dashed color-mix(in srgb, var(--v2-accent) 20%, var(--v2-line)); + border-radius: 9px; + font-size: 10px; +} + +.console-v2 .device-power-chart-empty[hidden] { + display: none; +} + +.console-v2 .device-power-chart-empty svg { + width: 21px; + height: 21px; + color: var(--v2-accent); +} + +.console-v2 .device-power-chart-axis { + position: absolute; + inset: auto 12px 7px 36px; + display: flex; + justify-content: space-between; + color: var(--v2-muted); + font-size: 8px; +} + +.console-v2 .device-power-note { + display: flex; + align-items: flex-start; + gap: 7px; + padding-top: 2px; + color: var(--v2-muted); + font-size: 9px; + line-height: 1.5; +} + +.console-v2 .device-power-note svg { + width: 13px; + height: 13px; + flex: 0 0 13px; + margin-top: 1px; + color: var(--v2-accent); +} + +@media (max-width: 760px) { + .console-v2 .device-power-drawer { + width: calc(100vw - 16px); + inset: 8px; + } + + .console-v2 .device-power-overview { + grid-template-columns: minmax(0, 1fr); + } + + .console-v2 .device-power-energy-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: none; + } +} + +.console-v2 .cabin-compact-board .domain-panel::before { + display: none; +} + +.console-v2 .cabin-compact-board .domain-panel header { + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 9px; +} + +.console-v2 .cabin-compact-board .domain-icon, +.console-v2 .cabin-compact-board .joint-seat-panel .seat-identity > svg { + width: 34px; + height: 34px; + padding: 8px; + border: 0; + border-radius: 8px; +} + +.console-v2 .cabin-compact-board .domain-panel header strong, +.console-v2 .cabin-compact-board .joint-seat-panel .seat-identity strong { + font-size: 15px; + line-height: 1.25; +} + +.console-v2 .cabin-compact-board .domain-panel header small, +.console-v2 .cabin-compact-board .joint-seat-panel .seat-identity span { + color: var(--v2-muted); + font-size: 10px; +} + +.console-v2 .cabin-compact-board .device-state, +.console-v2 .cabin-compact-board .seat-channel-badge { + padding: 5px 8px; + color: var(--v2-accent-strong); + background: color-mix(in srgb, var(--v2-accent-soft) 67%, white); + border-radius: 12px; + font-size: 10px; +} + +.console-v2 .cabin-compact-board .domain-capability-line { + min-height: 32px; + display: flex; + align-items: center; + margin: 4px 0 0; + padding-bottom: 4px; + color: var(--v2-muted); + border-bottom: 1px solid var(--v2-line); + font-size: 10px; +} + +.console-v2 .cabin-compact-board .domain-capability-line span { + display: none; +} + +.console-v2 .cabin-compact-board .domain-readouts { + gap: 0 18px; + margin-top: 3px; +} + +.console-v2 .cabin-compact-board .domain-readouts > div, +.console-v2 .cabin-compact-board .roof-panel .domain-readouts > div, +.console-v2 .cabin-compact-board .ambient-panel .domain-readouts > div, +.console-v2 .cabin-compact-board .window-panel .domain-readouts > div, +.console-v2 .cabin-compact-board .lighting-panel .domain-readouts > div { + min-height: 40px; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 0; + background: transparent; + border: 0; + border-bottom: 1px solid var(--v2-line); + border-radius: 0; +} + +.console-v2 .cabin-compact-board .domain-readouts dt { + color: var(--v2-muted); + font-size: 12px; + font-weight: 500; +} + +.console-v2 .cabin-compact-board .domain-readouts dd, +.console-v2 .cabin-compact-board .roof-panel .domain-readouts dd, +.console-v2 .cabin-compact-board .ambient-panel .domain-readouts dd, +.console-v2 .cabin-compact-board .window-panel .domain-readouts dd, +.console-v2 .cabin-compact-board .lighting-panel .domain-readouts dd { + margin-left: auto; + color: var(--v2-ink); + font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 13px; + font-weight: 800; + text-align: right; +} + +.console-v2 .cabin-compact-board .climate-display { + min-height: 68px; + margin-top: 3px; + border-bottom: 1px solid var(--v2-line); +} + +.console-v2 .cabin-compact-board .climate-panel .zone-temperature { + min-height: 68px; + padding: 9px 10px; +} + +.console-v2 .cabin-compact-board .climate-panel .zone-temperature:first-child { + border-right: 1px solid var(--v2-line); +} + +.console-v2 .cabin-compact-board .climate-panel .zone-temperature span { + color: var(--v2-muted); + font-size: 11px; + font-weight: 500; +} + +.console-v2 .cabin-compact-board .climate-panel .zone-temperature strong { + color: color-mix(in srgb, var(--v2-accent) 72%, #2a86b7); + font-size: 18px; +} + +.console-v2 .cabin-compact-board .climate-panel .zone-temperature small, +.console-v2 .cabin-compact-board .climate-panel .zone-temperature small b { + font-size: 10px; +} + +.console-v2 .cabin-compact-board .domain-summary-visual { + height: 38px; + margin-top: 5px; + padding: 4px 0; + background: transparent; + border: 0; + border-bottom: 1px solid var(--v2-line); + border-radius: 0; +} + +.console-v2 .cabin-compact-board .joint-seat-panel { + grid-template-rows: 34px 34px 62px minmax(0, 1fr); +} + +.console-v2 .cabin-compact-board .joint-seat-panel .dynamic-mode-state { + min-height: 34px; + margin: 2px 0 0; + padding: 5px 0; + color: var(--v2-muted); + background: transparent; + border: 0; + border-bottom: 1px solid var(--v2-line); + border-radius: 0; + font-size: 11px; +} + +.console-v2 .cabin-compact-board .joint-seat-panel .dynamic-mode-state b { + font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 12px; +} + +.console-v2 .cabin-compact-board .joint-seat-panel .thermal-states { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; + margin: 0; + border-bottom: 1px solid var(--v2-line); +} + +.console-v2 .cabin-compact-board .joint-seat-panel .thermal-states > span { + min-height: 31px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto auto; + justify-items: center; + gap: 4px; + padding: 5px; + color: var(--v2-muted); + background: transparent; + border: 0; + border-left: 1px solid var(--v2-line); + font-size: 11px; + text-align: center; +} + +.console-v2 .cabin-compact-board .joint-seat-panel .thermal-states > span:first-child { + border-left: 0; +} + +.console-v2 .cabin-compact-board .joint-seat-panel .thermal-states svg { + display: none; +} + +.console-v2 .cabin-compact-board .joint-seat-panel .thermal-states b { + font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 12px; +} + +.console-v2 .cabin-compact-board .joint-seat-panel .motion-state-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 14px; + margin: 0; + padding: 0; +} + +.console-v2 .cabin-compact-board .joint-seat-panel .motion-axis { + min-height: 31px; + padding: 3px 0; + color: var(--v2-muted); + background: transparent; + border: 0; + border-bottom: 1px solid var(--v2-line); + border-radius: 0; + font-size: 11px; +} + +.console-v2 .cabin-compact-board .joint-seat-panel .motion-axis b { + font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 12px; +} + +.console-v2 .cabin-telemetry-source[hidden] { + display: none !important; +} + +.console-v2 .joint-twin-shell.cabin-expanded { + grid-template-columns: minmax(0, 1fr); +} + +.console-v2 .joint-twin-shell.cabin-expanded > .twin-band { + display: none; +} + +.console-v2 .joint-twin-shell.cabin-expanded > .cabin-band { + grid-column: 1 / -1; +} + +@media (max-height: 820px) { + .console-v2 .joint-twin-shell > .twin-band { + grid-template-rows: 54px minmax(0, 1fr); + } + + .console-v2 .joint-twin-shell > .cabin-band.cabin-compact-board { + grid-template-rows: 54px 74px minmax(0, 1fr); + } + + .console-v2 .joint-twin-shell .twin-heading, + .console-v2 .cabin-compact-board .cabin-heading { + padding: 7px 10px; + } + + .console-v2 .joint-twin-shell .twin-heading h3, + .console-v2 .cabin-compact-board .cabin-heading h3 { + font-size: 15px; + } + + .console-v2 .cabin-board-toolbar { + grid-template-rows: 36px 29px; + padding: 7px 8px 0; + } + + .console-v2 .cabin-compact-board .domain-grid { + --cabin-card-gap: 8px; + margin-inline: 8px; + padding: 8px 0; + } + + .console-v2 .cabin-compact-board .domain-panel { + padding: 10px 12px 46px; + } + + .console-v2 .cabin-compact-board .device-power-entry { + height: 32px; + inset: auto 12px 8px; + padding: 0 8px; + } + + .console-v2 .cabin-compact-board .device-power-copy small, + .console-v2 .cabin-compact-board .device-power-action { + font-size: 9px; + } + + .console-v2 .cabin-compact-board .device-power-copy b { + font-size: 12px; + } + + .console-v2 .cabin-compact-board .domain-capability-line { + min-height: 26px; + margin-top: 2px; + padding-bottom: 2px; + } + + .console-v2 .cabin-compact-board .domain-summary-visual { + height: 30px; + margin-top: 2px; + padding: 2px 0; + } + + .console-v2 .cabin-compact-board .domain-readouts { + margin-top: 0; + } + + .console-v2 .cabin-compact-board .domain-readouts > div, + .console-v2 .cabin-compact-board .roof-panel .domain-readouts > div, + .console-v2 .cabin-compact-board .ambient-panel .domain-readouts > div, + .console-v2 .cabin-compact-board .window-panel .domain-readouts > div, + .console-v2 .cabin-compact-board .lighting-panel .domain-readouts > div { + min-height: 34px; + padding: 3px 0; + } + + .console-v2 .cabin-compact-board .climate-display, + .console-v2 .cabin-compact-board .climate-panel .zone-temperature { + min-height: 58px; + } + + .console-v2 .cabin-compact-board .climate-panel .zone-temperature { + padding-block: 6px; + } + + .console-v2 .cabin-compact-board .domain-panel header strong, + .console-v2 .cabin-compact-board .joint-seat-panel .seat-identity strong { + font-size: 14px; + } + + .console-v2 .cabin-compact-board .domain-panel header small, + .console-v2 .cabin-compact-board .joint-seat-panel .seat-identity span { + display: block; + } + + .console-v2 .cabin-compact-board .domain-capability-line, + .console-v2 .cabin-compact-board .domain-readouts dt, + .console-v2 .cabin-compact-board .joint-seat-panel .thermal-states > span, + .console-v2 .cabin-compact-board .joint-seat-panel .motion-axis { + font-size: 10px; + } + + .console-v2 .cabin-compact-board .domain-readouts dd, + .console-v2 .cabin-compact-board .joint-seat-panel .thermal-states b, + .console-v2 .cabin-compact-board .joint-seat-panel .motion-axis b { + font-size: 11px; + } +} diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js index 46e0052..f4cf524 100644 --- a/apps/web-console/console-v2.js +++ b/apps/web-console/console-v2.js @@ -21,7 +21,9 @@ controlMode: sessionStorage.getItem("cognitive-agent.control-mode") === "gesture" ? "gesture" : "natural", - activeModule: storedModule === "self-extension" + activeModule: storedModule === "self-evolution" + ? "self-evolution" + : storedModule === "self-extension" ? "self-extension" : storedModule === "self-calibration" ? "self-calibration" @@ -392,11 +394,11 @@ const naturalPanel = $("#v2NaturalPanel"); if (panelHeading) { - panelHeading.classList.add("v2-operation-heading"); + panelHeading.classList.add("v2-operation-heading", "v2-module-heading"); const kicker = $(".section-kicker", panelHeading); const title = $("h2", panelHeading); const intro = $(".panel-intro", panelHeading); - if (kicker) kicker.textContent = "自然语言控制"; + if (kicker) kicker.textContent = "SELF-ORCHESTRATION"; if (title) title.textContent = "一句话,调好整车"; if (intro) intro.textContent = "座椅、空调、天幕与氛围灯,一句话联动完成"; naturalPanel.append(panelHeading); @@ -417,8 +419,8 @@ } const gesturePanel = $("#v2GesturePanel"); - const gestureHeading = make("header", "v2-operation-heading v2-gesture-operation-heading", ` - 手势控制 + const gestureHeading = make("header", "v2-operation-heading v2-module-heading v2-gesture-operation-heading", ` + SELF-ORCHESTRATION

抬抬手,控制整车

通过摄像头识别手势,直观控制车舱功能

`); gesturePanel.append(gestureHeading); @@ -464,13 +466,13 @@ function buildModuleSwitcher(mainPanel) { const modules = [ - { id: "self-orchestration", label: "自编排", available: true }, { id: "self-calibration", label: "自标定", available: true, adminOnly: true, }, + { id: "self-orchestration", label: "自编排", available: true }, { id: "self-extension", label: "自扩展", available: true }, { id: "self-repair", @@ -478,6 +480,7 @@ available: true, adminOnly: true, }, + { id: "self-evolution", label: "自进化", available: true }, ]; const switcher = make("nav", "v2-module-switcher", ""); switcher.id = "v2ModuleSwitcher"; @@ -501,6 +504,216 @@ mainPanel.prepend(switcher); } + function buildEvolutionJournal(mainPanel) { + const evolutionEntries = [ + { + version: "v0.5", + date: "2026.08.12", + status: "当前版本", + title: "统一主题与权限体验", + summary: "把权限、主题与核心交互收束到同一套体验规则,让不同模块看起来、用起来都属于同一个系统。", + changes: [ + ["主题统一", "自扩展固定使用 user 绿色主题,避免权限语义混用。"], + ["孪生同步", "3D 模型渲染、玩具动作和车端动作统一跟随当前主题。"], + ["能力扩充", "新增自进化入口,让 user 与 admin 共同查看演进过程。"], + ], + modules: ["自扩展", "数字孪生", "权限体系"], + metrics: [ + ["主题一致性", "72%", "100%", 100], + ["可见能力模块", "4 个", "5 个", 92], + ["待修正视觉项", "4 项", "0 项", 100], + ], + userValidation: "核心板块已完成主题复核,权限色与交互反馈保持一致。", + adminValidation: "前端契约 11/11 · 自标定/自修复检查 8/8", + }, + { + version: "v0.4", + date: "2026.08.11", + status: "稳定版本", + title: "自修复闭环增强", + summary: "从发现异常走向诊断、修复与验证闭环,同时补齐离线场景的默认内容,确保功能状态始终可见。", + changes: [ + ["故障覆盖", "补充六类量产故障用例,覆盖更真实的车端异常。"], + ["修复闭环", "形成发现、定位、决策、执行、验证五阶段流程。"], + ["离线韧性", "服务暂时离线时仍保留默认案例和演示路径。"], + ], + modules: ["自修复", "故障诊断", "离线体验"], + metrics: [ + ["故障用例", "2 类", "6 类", 86], + ["修复阶段", "3 阶段", "5 阶段", 92], + ["离线可用性", "空白", "可演示", 100], + ], + userValidation: "断开服务后仍可理解自修复能力,并可继续浏览默认案例。", + adminValidation: "默认数据回退、流程状态与接口异常路径均已验证。", + }, + { + version: "v0.3", + date: "2026.08.10", + status: "能力升级", + title: "外设与车端统一编排", + summary: "将玩具动作、车端设备动作与数字孪生放进同一个控制流程,减少跨区域理解和操作成本。", + changes: [ + ["动作融合", "玩具动作与车端设备动作进入统一动作界面。"], + ["布局优化", "孪生界面位于左侧,动作界面固定在右侧。"], + ["映射精简", "移除重复外框,让实时映射关系更直接。"], + ], + modules: ["自扩展", "外设学习", "整车控制"], + metrics: [ + ["动作入口", "2 处", "1 处", 100], + ["映射层级", "3 层", "2 层", 88], + ["协同对象", "玩具", "玩具 + 车端", 94], + ], + userValidation: "动作与孪生左右分区清晰,学习结果可以直接用于统一控制。", + adminValidation: "外设连接状态、动作映射与车端执行链路保持独立可追踪。", + }, + { + version: "v0.2", + date: "2026.08.09", + status: "体系建立", + title: "多权限主题体系", + summary: "建立 user 与 admin 的视觉身份,使权限差异不只体现在可用功能,也能被用户直观感知。", + changes: [ + ["角色主题", "user 使用绿色,admin 使用蓝色,形成稳定的权限认知。"], + ["能力边界", "按角色控制模块入口,同时保留清晰的切换反馈。"], + ["模型联动", "数字孪生模型与界面主题保持同色语义。"], + ], + modules: ["权限体系", "主题系统", "数字孪生"], + metrics: [ + ["角色主题", "1 套", "2 套", 100], + ["权限反馈", "文字", "视觉 + 文字", 90], + ["主题联动", "界面", "界面 + 模型", 96], + ], + userValidation: "绿色主题贯穿 user 可用能力,角色身份更加明确。", + adminValidation: "角色切换事件可同步驱动界面变量与模型渲染参数。", + }, + { + version: "v0.1", + date: "2026.08.08", + status: "起点版本", + title: "整车认知控制台诞生", + summary: "完成从自然语言意图到整车状态回读的第一条闭环,建立后续能力持续进化的共同底座。", + changes: [ + ["自然语言控制", "用贴近日常表达的指令控制座舱功能。"], + ["整车孪生", "把座椅、空调、车窗等状态集中映射到一个界面。"], + ["执行回读", "串联 CAN 计划、设备执行与状态反馈。"], + ], + modules: ["自编排", "数字孪生", "CAN 执行"], + metrics: [ + ["能力模块", "0 个", "1 个", 100], + ["控制链路", "分散", "闭环", 92], + ["状态视图", "单设备", "整车", 96], + ], + userValidation: "自然语言、执行过程与结果反馈首次形成连续体验。", + adminValidation: "意图解析、CAN 编译计划与硬件回读具备完整追踪链路。", + }, + ]; + + const versionButtons = evolutionEntries.map((entry, index) => ` + `).join(""); + + const evolution = make("section", "v2-self-evolution", ` +
+
+
+ SELF-EVOLUTION +

能力进化日志

+

记录每次问题发现、能力升级与验证结果,见证系统持续进化。

+
+
+
当前版本
v0.5
能力融合期
+
累计迭代
5
个版本
+
能力改进
12
项记录
+
最近更新
08.12
2026
+
+
+ +
+ + +
+
+
+ + +
+ +
+

+

+
+
+

本次进化

+
+
+
+

变好的证据

+
+
+
+
+
+

+
+
+
+ + +
`); + evolution.id = "v2SelfEvolution"; + evolution.setAttribute("aria-label", "自进化能力日志"); + mainPanel.append(evolution); + + const renderEntry = (index) => { + const entry = evolutionEntries[index]; + if (!entry) return; + $("#v2EvolutionStatus", evolution).textContent = entry.status; + $("#v2EvolutionVersion", evolution).textContent = entry.version; + const date = $("#v2EvolutionDate", evolution); + date.textContent = entry.date; + date.dateTime = entry.date.replaceAll(".", "-"); + $("#v2EvolutionTitle", evolution).textContent = entry.title; + $("#v2EvolutionLead", evolution).textContent = entry.summary; + $("#v2EvolutionChangeList", evolution).innerHTML = entry.changes.map(([label, copy]) => ` +

${label}${copy}

`).join(""); + $("#v2EvolutionMetrics", evolution).innerHTML = entry.metrics.map(([label, before, after, progress]) => ` +
+
${label}

${before}${after}

+ +
`).join(""); + $("#v2EvolutionModules", evolution).innerHTML = entry.modules + .map((module) => `${module}`).join(""); + $("#v2EvolutionUserValidation", evolution).textContent = entry.userValidation; + $("#v2EvolutionAdminValidation", evolution).textContent = entry.adminValidation; + $$("[data-evolution-index]", evolution).forEach((button, buttonIndex) => { + const active = buttonIndex === index; + button.classList.toggle("active", active); + button.setAttribute("aria-pressed", String(active)); + }); + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + }; + + $(".v2-evolution-version-list", evolution)?.addEventListener("click", (event) => { + const button = event.target.closest("[data-evolution-index]"); + if (button) renderEntry(Number(button.dataset.evolutionIndex)); + }); + renderEntry(0); + } + function buildOutput(mainPanel) { const output = make("section", "v2-output-shell", `
@@ -652,14 +865,17 @@ function setModule(module, { persist = true } = {}) { if (["self-calibration", "self-repair"].includes(module) && ui.role !== "admin") { - setRole("admin"); + setRole("admin", { allowModuleTransition: true }); } - const allowed = new Set(["self-orchestration", "self-extension"]); + const allowed = new Set(["self-orchestration", "self-extension", "self-evolution"]); if (ui.role === "admin") { allowed.add("self-calibration"); allowed.add("self-repair"); } ui.activeModule = allowed.has(module) ? module : "self-orchestration"; + if (ui.activeModule === "self-extension" && ui.role !== "user") { + setRole("user"); + } if (persist) { sessionStorage.setItem("cognitive-agent.active-module", ui.activeModule); } @@ -675,7 +891,7 @@ button.setAttribute("aria-selected", String(active)); button.tabIndex = active ? 0 : -1; }); - if (["self-extension", "self-calibration", "self-repair"].includes(ui.activeModule)) { + if (["self-extension", "self-calibration", "self-repair", "self-evolution"].includes(ui.activeModule)) { const drawer = $("#gestureDrawer"); if (drawer?.classList.contains("open")) $("#gestureCloseButton")?.click(); } else { @@ -687,7 +903,10 @@ window.setTimeout(() => window.dispatchEvent(new Event("resize")), 120); } - function setRole(role) { + function setRole(role, { allowModuleTransition = false } = {}) { + if (ui.activeModule === "self-extension" && role !== "user" && !allowModuleTransition) { + role = "user"; + } if (ui.activeModule === "self-repair" && role !== "admin") { const roleSelect = $("#v2RoleSelect"); if (roleSelect) { @@ -946,6 +1165,7 @@ gestureDrawer, ); buildModuleSwitcher(mainPanel); + buildEvolutionJournal(mainPanel); $(".pipeline", mainPanel)?.classList.add("v2-legacy-hidden"); $(".topbar")?.classList.add("v2-legacy-hidden"); diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 8d84059..a5d661e 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -10,23 +10,23 @@ 智舱随行 · 整车智能控制台 - - - - + + + + - + - - - - - + + + + +
@@ -393,7 +393,8 @@
-
+
+ LIVE VEHICLE DIGITAL TWIN

整车孪生界面

-
-
ZED / SEATPOSE

双目位姿数据

等待数据
-
消息总数0
最新帧—
解析错误0
-

MQTT 尚未连接

-
-
-
HALL ↔ 3D实时映射
-
-
-
-
@@ -312,9 +300,7 @@ renderWorkflow(state.operation.phase); renderAxes(state.axes, controller.job_active, transport.state === "online"); renderSeat(state.axes); - renderMqtt(mqtt); renderVideo(state.video); - renderMappings(state.axes, mqtt.areas); $("#calibrationStartAll").disabled = transport.state !== "online" || controller.job_active; if (!settingsHydrated) hydrateSettings(state.settings, state.video.ffmpeg_available); @@ -408,55 +394,25 @@ }; window.n7SeatPose = pose; window.dispatchEvent(new CustomEvent("n7-seat-pose", { detail: pose })); + window.dispatchEvent(new CustomEvent("calibration-seat-state", { + detail: { + axes: [ + { motor: 0, position_pct: longitudinal.permille / 10 }, + { motor: 1, position_pct: height.permille / 10 }, + { motor: 2, position_pct: backrest.permille / 10 }, + { motor: 3, position_pct: legrest.permille / 10 }, + ], + state: axes.some((axis) => axis.fault) + ? "FAILED" + : axes.some((axis) => axis.moving || axis.calibrating) ? "WORKING" : "IDLE", + }, + })); $("#calibrationTagLong").textContent = `${longitudinal.physical_value.toFixed(2)} cm`; $("#calibrationTagLeg").textContent = `${legrest.physical_value.toFixed(1)}°`; $("#calibrationTagBack").textContent = `${backrest.physical_value.toFixed(1)}°`; $("#calibrationTagHeight").textContent = `${height.physical_value.toFixed(2)} cm`; } - function renderMqtt(mqtt) { - const friendly = { - simulated: "仿真数据", - subscribed: "已订阅", - connecting: "连接中", - disconnected: "未连接", - error: "异常", - }; - $("#calibrationMqttBadge").textContent = friendly[mqtt.state] || mqtt.state; - $("#calibrationMqttBadge").className = `v2-calibration-badge ${ - mqtt.state === "error" - ? "error" - : ["disconnected", "connecting"].includes(mqtt.state) ? "wait" : "" - }`; - $("#calibrationMqttCount").textContent = mqtt.count; - $("#calibrationMqttFrame").textContent = mqtt.last_frame >= 0 ? mqtt.last_frame : "—"; - $("#calibrationMqttErrors").textContent = mqtt.parse_errors; - $("#calibrationMqttDetail").textContent = mqtt.detail; - const container = $("#calibrationPoseAreas"); - container.replaceChildren(); - const entries = Object.entries(mqtt.areas || {}); - if (!entries.length) { - const empty = document.createElement("p"); - empty.className = "v2-calibration-empty"; - empty.textContent = "等待 Seat_cushion / Seat_backrest / Seat_leg_rest 位姿…"; - container.append(empty); - } - entries.forEach(([name, pose]) => { - const row = document.createElement("div"); - row.className = "v2-calibration-pose-row"; - const title = document.createElement("strong"); - title.textContent = name; - const angle = document.createElement("span"); - angle.textContent = pose.angle == null ? "ANGLE —" : `${Number(pose.angle).toFixed(2)}°`; - const translation = document.createElement("small"); - translation.textContent = pose.translation - ? `T [${pose.translation.slice(0, 3).map((value) => Number(value).toFixed(3)).join(", ")}]` - : "T —"; - row.append(title, angle, translation); - container.append(row); - }); - } - function renderVideo(video) { const friendly = { idle: "未启动", connecting: "连接中", playing: "播放中", error: "异常" }; $("#calibrationVideoBadge").textContent = friendly[video.state] || video.state; @@ -475,28 +431,6 @@ } } - function renderMappings(axes, areas = {}) { - const container = $("#calibrationMappingRows"); - container.replaceChildren(); - const areaFor = ["Seat_cushion", "Seat_leg_rest", "Seat_backrest", "Seat_cushion"]; - axes.forEach((axis) => { - const pose = areas[areaFor[axis.id]] || {}; - const observed = axis.id === 0 || axis.id === 3 - ? pose.translation?.[axis.id === 0 ? 0 : 1] - : pose.angle; - const row = document.createElement("div"); - row.className = "v2-calibration-mapping-row"; - row.innerHTML = "
"; - $("strong", row).textContent = axis.name; - $("span", row).textContent = `${axis.hall} Hall`; - $("i", row).style.width = `${axis.permille / 10}%`; - $("b", row).textContent = observed == null - ? "3D —" - : `${Number(observed).toFixed(3)}${axis.id === 1 || axis.id === 2 ? "°" : " T"}`; - container.append(row); - }); - } - function hydrateSettings(settings, ffmpegAvailable) { settingsHydrated = true; $("#calibrationCanInterface").value = settings.can.interface; @@ -580,19 +514,6 @@ }, settings.can.mode === "simulation" ? "标定仿真台架已连接" : "真实台架连接完成"); } - function loadSeatModel() { - if (modelRequested || !$("#seatModelHost")) return; - modelRequested = true; - const script = document.createElement("script"); - script.src = "/assets/self-calibration-model.bundle.js"; - script.async = true; - script.onerror = () => { - modelRequested = false; - $("#seatModelLoading")?.classList.add("error"); - }; - document.head.append(script); - } - function closeEvents() { window.clearTimeout(reconnectTimer); reconnectTimer = 0; @@ -621,7 +542,6 @@ closeEvents(); return; } - loadSeatModel(); fetchState().catch((error) => toast(`自标定服务连接失败:${error.message}`, true)); connectEvents(); } @@ -641,18 +561,6 @@ () => api("/start", { axis: "all" }), "四轴自动标定已启动", )); - $("#calibrationMqttConnect").addEventListener("click", () => run( - () => api("/mqtt/connect"), - "正在连接 MQTT", - )); - $("#calibrationMqttDisconnect").addEventListener("click", () => run( - () => api("/mqtt/disconnect"), - "MQTT 已断开", - )); - $("#calibrationMqttTest").addEventListener("click", () => run( - () => api("/mqtt/test"), - "MQTT 测试帧已发送", - )); $("#calibrationVideoStart").addEventListener("click", () => run( () => api("/video/start"), "正在启动 RTSP 接收", diff --git a/apps/web-console/self-extension.css b/apps/web-console/self-extension.css index 609cd71..3620b08 100644 --- a/apps/web-console/self-extension.css +++ b/apps/web-console/self-extension.css @@ -109,7 +109,7 @@ } .v2-extension-mode-switch button { - min-height: 52px; + min-height: var(--v2-sidebar-item-height); display: grid; grid-template-columns: 30px minmax(0, 1fr); align-items: center; @@ -133,14 +133,15 @@ .v2-extension-mode-switch button:hover { color: var(--v2-accent-strong); - background: rgba(255, 255, 255, 0.52); + background: color-mix(in srgb, var(--v2-accent-soft) 34%, white); + border-color: color-mix(in srgb, var(--v2-accent) 10%, white); } .v2-extension-mode-switch button.active { color: #111f20; background: rgba(255, 255, 255, 0.94); - border-color: rgba(255, 255, 255, 0.86); - box-shadow: 0 10px 28px rgba(34, 93, 92, 0.08); + border-color: color-mix(in srgb, var(--v2-accent) 18%, white); + box-shadow: 0 10px 28px color-mix(in srgb, var(--v2-accent) 10%, transparent); } .v2-extension-mode-switch button.active svg { @@ -161,7 +162,7 @@ .v2-extension-config-card { min-width: 0; - min-height: 43px; + min-height: var(--v2-sidebar-card-height); display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; @@ -169,7 +170,7 @@ padding: 6px 8px; background: rgba(255, 255, 255, 0.72); border: 1px solid rgba(255, 255, 255, 0.78); - border-radius: 10px; + border-radius: var(--v2-sidebar-card-radius); } .v2-extension-config-card > svg { @@ -252,11 +253,12 @@ } .v2-extension-view { + --v2-extension-view-pad: clamp(13px, 1.7vw, 24px); width: 100%; height: 100%; min-width: 0; min-height: 0; - padding: clamp(13px, 1.7vw, 24px); + padding: var(--v2-module-title-block) var(--v2-extension-view-pad) var(--v2-extension-view-pad); overflow: hidden; background: radial-gradient(circle at 31% -10%, rgba(216, 244, 241, 0.72), transparent 26%), @@ -295,7 +297,7 @@ align-items: flex-start; justify-content: space-between; gap: 20px; - padding: 0 3px; + padding-inline: max(0px, calc(var(--v2-module-title-inline) - var(--v2-extension-view-pad))); } .v2-extension-heading h1 { @@ -866,6 +868,20 @@ button.v2-extension-capability.preview { border-radius: 11px; text-align: left; cursor: pointer; + transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; +} + +.console-v2[data-module="self-extension"] +.v2-extension-control +.v2-extension-learned-action { + --extension-card-color: var(--v2-accent); +} + +.v2-extension-control .v2-extension-learned-action:hover, +.v2-extension-control .v2-extension-learned-action:focus-visible { + background: color-mix(in srgb, var(--v2-accent) 10%, white); + border-color: color-mix(in srgb, var(--v2-accent) 42%, #dce7e4); + box-shadow: 0 5px 14px color-mix(in srgb, var(--v2-accent) 10%, transparent); } .v2-extension-learned-action > span { @@ -1117,13 +1133,9 @@ button.v2-extension-capability.preview { min-width: 0; min-height: 0; display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 9px; - padding: clamp(11px, 1.2vw, 16px); - background: rgba(255, 255, 255, 0.96); - border: 1px solid #e1e9e7; - border-radius: 19px; - box-shadow: 0 13px 34px rgba(29, 78, 78, 0.07); + grid-template-rows: minmax(0, 1fr); + overflow: hidden; + border-radius: 17px; } .v2-extension-control > .v2-extension-control-grid, @@ -1132,20 +1144,20 @@ button.v2-extension-capability.preview { } .v2-extension-control > .v2-extension-control-grid { - grid-column: 1; -} - -.v2-extension-control > .v2-extension-twin-board { grid-column: 2; } -.v2-extension-control > #extensionVehicleStateSummary { +.v2-extension-control > .v2-extension-twin-board { grid-column: 1; +} + +.v2-extension-control > #extensionVehicleStateSummary { + grid-column: 2; grid-row: 4; } .v2-extension-control > #extensionRunPlan { - grid-column: 2; + grid-column: 1; grid-row: 4; } @@ -1808,7 +1820,7 @@ form > .v2-extension-primary { @media (max-height: 820px) { .console-v2[data-module="self-extension"] .main-panel { - grid-template-rows: 34px minmax(0, 1fr); + grid-template-rows: 36px minmax(0, 1fr); } .console-v2[data-module="self-extension"] .v2-extension-sidebar { @@ -1840,7 +1852,8 @@ form > .v2-extension-primary { } .v2-extension-view { - padding: 10px; + --v2-extension-view-pad: 10px; + padding: var(--v2-module-title-block) var(--v2-extension-view-pad) var(--v2-extension-view-pad); } .v2-extension-heading h1 { @@ -1934,8 +1947,8 @@ form > .v2-extension-primary { } .v2-extension-twin-board { - gap: 5px; - padding: 7px; + gap: 0; + padding: 0; } .v2-extension-run-plan { diff --git a/apps/web-console/self-extension.js b/apps/web-console/self-extension.js index 8144256..4793edd 100644 --- a/apps/web-console/self-extension.js +++ b/apps/web-console/self-extension.js @@ -465,7 +465,7 @@ root.innerHTML = `
-
PERIPHERAL LEARNING

外设玩具能力一览与学习

为六类外设添加具体硬件,学习并验证遥控动作。

+
SELF-EXTENSION

外设玩具能力学习

添加外设硬件,学习并验证可执行的遥控动作。

@@ -475,7 +475,7 @@
-
-
-
SHARED VEHICLE DIGITAL TWIN

整车孪生实时映射

与自编排复用同一孪生实例,CH0 副驾座椅与 CH1 主驾座椅 / 车身状态持续同步。

- 实际反馈 / 当前动作 -
+
正在挂载整车孪生界面
diff --git a/apps/web-console/self-repair-seat3d.js b/apps/web-console/self-repair-seat3d.js index 8486b4b..7edfe88 100644 --- a/apps/web-console/self-repair-seat3d.js +++ b/apps/web-console/self-repair-seat3d.js @@ -1,13 +1,27 @@ (function () { "use strict"; - function init() { + function initSeat3d(options) { + const { + canvasId, + viewerId, + stateEvent, + resetEvent, + initialAxes = [35, 42, 48, 20], + readyClass = "ready", + axisMode = "repair", + } = options; const THREE = window.THREE; - const canvas = document.getElementById("repairSeatCanvas"); - const viewer = document.getElementById("repairSeatViewer"); + const canvas = document.getElementById(canvasId); + const viewer = document.getElementById(viewerId); if (!canvas || !viewer || viewer.dataset.seat3dInitialized === "true") return; viewer.dataset.seat3dInitialized = "true"; + const markReady = (fallback = false) => { + viewer.classList.add(readyClass); + if (fallback) viewer.classList.add("fallback"); + }; + const buildFallback = () => { const fallback = document.createElement("div"); fallback.className = "v2-repair-seat-fallback"; @@ -23,19 +37,19 @@ `; viewer.insertBefore(fallback, canvas.nextSibling); - window.addEventListener("seat-healing-state", (event) => { + window.addEventListener(stateEvent, (event) => { const detail = event.detail || {}; const axes = new Map((detail.axes || []).map((axis) => [Number(axis.motor), Number(axis.position_pct || 0)])); - fallback.style.setProperty("--repair-slide", `${(50 - (axes.get(0) ?? 35)) * 0.18}px`); - fallback.style.setProperty("--repair-lift", `${((axes.get(1) ?? 42) - 50) * 0.1}px`); - fallback.style.setProperty("--repair-back", `${-8 - (axes.get(2) ?? 48) * 0.24}deg`); - fallback.style.setProperty("--repair-leg", `${-5 + (axes.get(3) ?? 20) * 0.58}deg`); + fallback.style.setProperty("--repair-slide", `${(50 - (axes.get(0) ?? initialAxes[0])) * 0.18}px`); + fallback.style.setProperty("--repair-lift", `${((axes.get(1) ?? initialAxes[1]) - 50) * 0.1}px`); + fallback.style.setProperty("--repair-back", `${-8 - (axes.get(2) ?? initialAxes[2]) * 0.24}deg`); + fallback.style.setProperty("--repair-leg", `${-5 + (axes.get(3) ?? initialAxes[3]) * 0.58}deg`); fallback.classList.toggle("heating", Number(detail.heatingLevel || 0) > 0); fallback.classList.toggle("ventilating", Boolean(detail.ventilation)); fallback.classList.toggle("active", !["IDLE", "PASSED"].includes(detail.state)); fallback.classList.toggle("failed", detail.state === "FAILED"); }); - viewer.classList.add("ready", "fallback"); + markReady(true); }; if (!THREE) { @@ -340,8 +354,8 @@ failed: { shell: 0x7a3f43, insert: 0xc97a78, bolster: 0x85494c, emissive: 0x4a100d }, }; - const current = { axes: [35, 42, 48, 20], ventilation: false, heating: 0, active: false, state: "IDLE" }; - const desired = { axes: [35, 42, 48, 20], ventilation: false, heating: 0, active: false, state: "IDLE" }; + const current = { axes: [...initialAxes], ventilation: false, heating: 0, active: false, state: "IDLE" }; + const desired = { axes: [...initialAxes], ventilation: false, heating: 0, active: false, state: "IDLE" }; function updateCamera() { const cosPitch = Math.cos(orbit.pitch); @@ -399,7 +413,13 @@ current.state = desired.state; slideRoot.position.z = THREE.MathUtils.mapLinear(current.axes[0], 0, 100, -0.46, 0.46); - cushionPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[1], 0, 100, -0.08, 0.16); + if (axisMode === "calibration") { + slideRoot.position.y = THREE.MathUtils.mapLinear(current.axes[1], 0, 100, -0.12, 0.16); + cushionPivot.rotation.x = 0; + } else { + slideRoot.position.y = 0; + cushionPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[1], 0, 100, -0.08, 0.16); + } backrestPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[2], 0, 100, 0.16, -0.44); legrestPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[3], 0, 100, 0.08, -0.68); @@ -463,18 +483,36 @@ updateCamera(); }, { passive: false }); viewer.addEventListener("dblclick", resetOrbit); - window.addEventListener("seat-healing-state", onHealingState); - window.addEventListener("self-repair-seat-reset", resetOrbit); + window.addEventListener(stateEvent, onHealingState); + window.addEventListener(resetEvent, resetOrbit); if (window.ResizeObserver) new ResizeObserver(resize).observe(viewer); window.addEventListener("resize", resize); resize(); resetOrbit(); animate(); - window.requestAnimationFrame(() => viewer.classList.add("ready")); + window.requestAnimationFrame(() => markReady()); } + function init() { + initSeat3d({ + canvasId: "repairSeatCanvas", + viewerId: "repairSeatViewer", + stateEvent: "seat-healing-state", + resetEvent: "self-repair-seat-reset", + }); + initSeat3d({ + canvasId: "calibrationSeatCanvas", + viewerId: "calibrationSeatViewer", + stateEvent: "calibration-seat-state", + resetEvent: "self-calibration-seat-reset", + initialAxes: [50, 50, 20, 0], + readyClass: "is-ready", + axisMode: "calibration", + }); + } + if (document.readyState === "complete") { init(); } else { diff --git a/apps/web-console/self-repair.css b/apps/web-console/self-repair.css index 49ad417..8688001 100644 --- a/apps/web-console/self-repair.css +++ b/apps/web-console/self-repair.css @@ -46,7 +46,7 @@ display: flex; flex: 1; flex-direction: column; - margin-top: clamp(20px, 3vh, 36px); + margin-top: clamp(22px, 3.5vh, 42px); overflow: hidden; } @@ -70,7 +70,7 @@ grid-row: 2; grid-template-rows: auto minmax(0, 1fr); gap: 11px; - padding: clamp(12px, 1.35vw, 20px) 0 0; + padding: var(--v2-module-title-block) 0 0; overflow: hidden; } @@ -88,12 +88,12 @@ } .v2-repair-view-switch button { - min-height: 58px; + min-height: var(--v2-sidebar-item-height); display: grid; grid-template-columns: 30px minmax(0, 1fr); align-items: center; gap: 10px; - padding: 8px 13px; + padding: 6px 15px; color: color-mix(in srgb, var(--v2-accent-strong) 56%, #65787a); background: transparent; border: 1px solid transparent; @@ -105,15 +105,15 @@ .v2-repair-view-switch button:hover { color: var(--v2-accent-strong); - background: color-mix(in srgb, var(--v2-accent-soft) 68%, white); - border-color: color-mix(in srgb, var(--v2-accent) 14%, white); + background: color-mix(in srgb, var(--v2-accent-soft) 34%, white); + border-color: color-mix(in srgb, var(--v2-accent) 10%, white); } .v2-repair-view-switch button.active { - color: var(--v2-accent-strong); - background: color-mix(in srgb, var(--v2-accent-soft) 82%, white); - border-color: color-mix(in srgb, var(--v2-accent) 24%, white); - box-shadow: 0 10px 28px color-mix(in srgb, var(--v2-accent) 14%, transparent); + color: #111f20; + background: rgba(255, 255, 255, 0.94); + border-color: color-mix(in srgb, var(--v2-accent) 18%, white); + box-shadow: 0 10px 28px color-mix(in srgb, var(--v2-accent) 10%, transparent); } .v2-repair-view-switch button > svg { @@ -124,7 +124,7 @@ } .v2-repair-view-switch button.active > svg { - color: var(--v2-accent-strong); + color: var(--v2-accent); } .v2-repair-view-switch button > span { @@ -142,11 +142,12 @@ .v2-repair-view-switch strong { font-size: 15px; + line-height: 1.2; } .v2-repair-view-switch small { color: color-mix(in srgb, var(--v2-accent) 34%, #829092); - font-size: 10px; + font-size: 9px; font-weight: 600; } @@ -156,21 +157,23 @@ .v2-repair-config-stack { display: grid; - gap: 7px; + gap: 8px; + padding-left: 9px; + border-left: 1px solid color-mix(in srgb, var(--v2-accent) 14%, transparent); } .v2-repair-config-card { min-width: 0; - min-height: 50px; + min-height: var(--v2-sidebar-card-height); display: grid; - grid-template-columns: 27px minmax(0, 1fr) auto; + grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; - gap: 9px; - padding: 8px 10px; + gap: 8px; + padding: 6px 8px; color: var(--repair-ink); - background: rgba(255, 255, 255, 0.52); - border: 1px solid var(--repair-line); - border-radius: 13px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(255, 255, 255, 0.78); + border-radius: var(--v2-sidebar-card-radius); } .v2-repair-config-card > svg { @@ -187,21 +190,25 @@ .v2-repair-config-card small { color: var(--repair-muted); - font-size: 10px; + font-size: 8px; font-weight: 650; } .v2-repair-config-card strong { overflow: hidden; color: var(--repair-heading); - font-size: 13px; + margin-top: 2px; + font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } .v2-repair-config-card > span { + padding: 3px 5px; color: var(--v2-accent-strong); - font: 800 9px/1.1 "IBM Plex Mono", Consolas, monospace; + background: color-mix(in srgb, var(--v2-accent-soft) 68%, white); + border-radius: 5px; + font: 800 7px/1.1 "IBM Plex Mono", Consolas, monospace; } .repair-execution-card .v2-config-card-select, @@ -210,12 +217,12 @@ } .repair-execution-card .v2-dropdown-trigger { - min-height: 21px; + min-height: 18px; padding: 0; color: var(--repair-heading); background: transparent; border: 0; - font-size: 13px; + font-size: 11px; font-weight: 800; box-shadow: none; } @@ -231,9 +238,10 @@ min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; - align-items: end; + align-items: start; gap: 16px; - padding: 0 2px; + margin-inline: var(--v2-module-title-inline); + padding: 0; } .v2-repair-heading h1 { @@ -416,6 +424,13 @@ transform: translateY(0); } +.v2-repair-toolbar button.danger { + color: #a63f47; + background: #fff0f1; + border-color: #efc4c7; + box-shadow: none; +} + .v2-repair-toolbar button:focus-visible { outline: 2px solid color-mix(in srgb, var(--v2-accent) 42%, white); outline-offset: 2px; @@ -438,8 +453,8 @@ min-width: 0; min-height: 0; display: grid; - grid-template-areas: "twin cases timeline"; - grid-template-columns: minmax(280px, 1.18fr) minmax(185px, 0.66fr) minmax(330px, 1.32fr); + grid-template-areas: "twin cases logs"; + grid-template-columns: minmax(280px, 1.18fr) minmax(210px, 0.72fr) minmax(360px, 1.4fr); gap: 9px; overflow: hidden; } @@ -482,34 +497,17 @@ .v2-repair-cases-panel { grid-area: cases; display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr) auto; } -.v2-repair-overall-progress { - height: 8px; - margin: 10px 12px 5px; - overflow: hidden; - background: #fff; - border: 1px solid var(--repair-line); - border-radius: 999px; -} - -.v2-repair-overall-progress span { - width: 0; - height: 100%; - display: block; - background: linear-gradient(90deg, var(--repair-active-blue), var(--repair-deep-blue)); - border-radius: inherit; - transition: width 260ms ease; -} - -#repairBatchSummary { - margin: 0 12px 7px; - overflow: hidden; +.v2-repair-injection-note { + margin: 9px 10px 6px; + padding: 8px 9px; color: var(--repair-muted); - font-size: 9px; - text-overflow: ellipsis; - white-space: nowrap; + background: color-mix(in srgb, var(--v2-accent-soft) 66%, white); + border-radius: 9px; + font-size: 8px; + line-height: 1.5; } .v2-repair-case-list { @@ -517,7 +515,7 @@ display: grid; align-content: start; gap: 6px; - padding: 0 10px 10px; + padding: 0 10px 8px; overflow: auto; } @@ -538,9 +536,9 @@ } .v2-repair-case-list button.selected { - background: #fff; - border-color: color-mix(in srgb, var(--v2-accent) 22%, white); - box-shadow: 0 7px 20px color-mix(in srgb, var(--v2-accent) 10%, transparent); + background: color-mix(in srgb, var(--v2-accent-soft) 76%, white); + border-color: color-mix(in srgb, var(--v2-accent) 72%, white); + box-shadow: none; } .v2-repair-case-list button > span { @@ -581,14 +579,59 @@ .v2-repair-case-list button[data-state="complete"] > i { background: var(--repair-deep-blue); } .v2-repair-case-list button[data-state="failed"] > i { background: var(--repair-danger); } +.v2-repair-injection-footer { + display: grid; + gap: 6px; + padding: 9px 10px 10px; + border-top: 1px solid var(--repair-line); +} + +.v2-repair-injection-footer small, +.v2-repair-injection-footer > span { + overflow: hidden; + color: var(--repair-muted); + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-repair-injection-footer > span { + padding: 6px 7px; + color: #9a6428; + background: #fff4e7; + border-radius: 7px; + text-align: center; +} + +.v2-repair-injection-footer button { + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + color: #fff; + background: linear-gradient(145deg, var(--v2-accent), var(--v2-accent-strong)); + border: 1px solid var(--v2-accent-strong); + border-radius: 9px; + font-size: 10px; + font-weight: 750; + cursor: pointer; +} + +.v2-repair-injection-footer button svg { width: 13px; height: 13px; } +.v2-repair-injection-footer button:disabled { color: var(--repair-muted); background: var(--repair-soft); border-color: var(--repair-line); cursor: not-allowed; } + .v2-repair-twin-panel, -.v2-repair-timeline-panel { +.v2-repair-log-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); } .v2-repair-twin-panel { grid-area: twin; } -.v2-repair-timeline-panel { grid-area: timeline; } +.v2-repair-log-panel { + grid-area: logs; + grid-template-rows: auto auto auto minmax(0, 1fr); +} .v2-repair-seat-viewer { min-height: 0; @@ -911,88 +954,94 @@ white-space: nowrap; } -.v2-repair-timeline { - min-height: 0; - padding: 9px 11px 10px; - overflow: auto; +.v2-repair-log-panel > header > b { + padding: 6px 10px; + border-radius: 999px; + font-size: 11px; } -.v2-repair-timeline article { - min-height: 70px; - position: relative; +.v2-repair-log-panel > header h2 { font-size: 16px; } + +.v2-repair-log-panel > header > b[data-state="healthy"] { color: #19805e; background: #e6f6ef; } +.v2-repair-log-panel > header > b[data-state="repairing"] { color: #a86a1f; background: #fff2de; } +.v2-repair-log-panel > header > b[data-state="fault"] { color: #ad4248; background: #fdebed; } +.v2-repair-log-panel > header > b[data-state="idle"] { color: var(--repair-muted); background: var(--repair-soft); } + +.v2-repair-log-summary { display: grid; - grid-template-columns: 18px minmax(0, 1fr); - gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 9px 10px; + border-bottom: 1px solid var(--repair-line); } -.v2-repair-timeline article:not(:last-child)::before { - width: 2px; - position: absolute; - top: 13px; - bottom: -2px; - left: 7px; - content: ""; - background: #c8ced0; -} - -.v2-repair-timeline article > i { - width: 15px; - height: 15px; - z-index: 1; - display: block; - margin-top: 4px; - background: var(--repair-pending); - border: 2px solid rgba(255, 255, 255, 0.94); - border-radius: 50%; - box-shadow: 0 0 0 1px var(--repair-line); -} - -.v2-repair-timeline article[data-state="complete"] > i { background: var(--repair-deep-blue); } -.v2-repair-timeline article[data-state="active"] > i { background: var(--repair-active-blue); box-shadow: 0 0 0 4px rgba(115, 166, 232, 0.14); } -.v2-repair-timeline article[data-state="failed"] > i { background: var(--repair-danger); } -.v2-repair-timeline article[data-state="complete"]::before { background: var(--repair-deep-blue); } - -.v2-repair-timeline article > div { +.v2-repair-log-summary > div { min-width: 0; - padding-bottom: 8px; + padding: 9px 10px; + background: var(--repair-soft); + border-radius: 9px; } -.v2-repair-timeline article header { - min-height: 24px; +.v2-repair-log-summary small { display: block; overflow: hidden; color: var(--repair-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.v2-repair-log-summary strong { display: block; margin-top: 5px; color: var(--repair-heading); font: 800 15px "IBM Plex Mono", Consolas, monospace; } + +.v2-repair-log-toolbar { + min-height: 44px; display: flex; align-items: center; justify-content: space-between; gap: 8px; - padding: 0 3px; + padding: 5px 10px; + border-bottom: 1px solid var(--repair-line); } -.v2-repair-timeline article header strong { - color: var(--repair-ink); - font-size: 12px; +.v2-repair-log-toolbar > div { display: flex; gap: 3px; } +.v2-repair-log-toolbar button { padding: 6px 9px; color: var(--repair-muted); background: transparent; border: 0; border-radius: 7px; font-size: 10px; font-weight: 750; cursor: pointer; } +.v2-repair-log-toolbar button.active { color: var(--v2-accent-strong); background: var(--v2-accent-soft); } +.v2-repair-log-toolbar > span { display: inline-flex; align-items: center; gap: 6px; color: var(--repair-muted); font-size: 9px; white-space: nowrap; } +.v2-repair-log-toolbar > span i { width: 7px; height: 7px; background: #26916d; border-radius: 50%; } + +.v2-repair-event-stream { + min-height: 0; + padding: 4px 10px 10px; + overflow: auto; } -.v2-repair-timeline article[data-state="complete"] header strong { color: var(--repair-deep-blue); } -.v2-repair-timeline article[data-state="active"] header strong { color: var(--v2-accent); } -.v2-repair-timeline article[data-state="failed"] header strong { color: var(--repair-danger); } - -.v2-repair-timeline article header span { - color: #909b9d; - font-size: 8px; - font-weight: 750; +.v2-repair-event-stream article { + min-height: 88px; + position: relative; + display: grid; + grid-template-columns: 78px 14px minmax(0, 1fr); + gap: 8px; + padding: 11px 0; + border-bottom: 1px solid var(--repair-line); } -.v2-repair-timeline article p { - min-height: 38px; - margin: 0; - padding: 8px 9px; - overflow: hidden; - color: var(--repair-muted); - background: var(--repair-soft); - border: 1px solid var(--repair-line); - border-radius: 9px; - font-size: 9px; - line-height: 1.45; -} +.v2-repair-event-stream article > time { padding-top: 3px; color: var(--repair-muted); font: 9px "IBM Plex Mono", Consolas, monospace; } +.v2-repair-event-stream article > i { width: 11px; height: 11px; z-index: 1; margin-top: 3px; background: var(--v2-accent); border: 2px solid #fff; border-radius: 50%; box-shadow: 0 0 0 1px var(--repair-line); } +.v2-repair-event-stream article:not(:last-child)::after { width: 1px; position: absolute; top: 27px; bottom: -2px; left: 91px; content: ""; background: var(--repair-line); } +.v2-repair-event-stream article[data-kind="fault"] > i { background: var(--repair-danger); } +.v2-repair-event-stream article[data-kind="diagnosis"] > i, +.v2-repair-event-stream article[data-kind="repair"] > i { background: var(--repair-warn); } +.v2-repair-event-stream article[data-kind="success"] > i, +.v2-repair-event-stream article[data-kind="heartbeat"] > i { background: #248b67; } +.v2-repair-event-stream article > div { min-width: 0; } +.v2-repair-event-stream article header { min-height: 21px; display: flex; align-items: center; gap: 8px; padding: 0; border: 0; } +.v2-repair-event-stream article header span { padding: 3px 5px; color: var(--repair-muted); background: var(--repair-soft); border-radius: 5px; font-size: 9px; font-weight: 750; } +.v2-repair-event-stream article header strong { overflow: hidden; color: var(--repair-ink); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.v2-repair-event-stream article[data-kind="fault"] header strong { color: var(--repair-danger); } +.v2-repair-event-stream article[data-kind="diagnosis"] header strong, +.v2-repair-event-stream article[data-kind="repair"] header strong { color: #a7681d; } +.v2-repair-event-stream article[data-kind="success"] header strong, +.v2-repair-event-stream article[data-kind="heartbeat"] header strong { color: #197c5c; } +.v2-repair-event-stream article p { margin: 5px 0 0; color: var(--repair-muted); font-size: 10px; line-height: 1.55; } +.v2-repair-event-stream article footer { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; } +.v2-repair-event-stream article footer span { padding: 3px 6px; color: var(--repair-muted); background: var(--repair-soft); border-radius: 5px; font-size: 9px; } + +.v2-repair-log-empty { height: 100%; display: grid; place-content: center; justify-items: center; gap: 6px; padding: 24px; color: var(--repair-muted); text-align: center; } +.v2-repair-log-empty svg { width: 24px; height: 24px; color: var(--v2-accent); } +.v2-repair-log-empty strong { color: var(--repair-ink); font-size: 14px; } +.v2-repair-log-empty span { max-width: 280px; font-size: 10px; line-height: 1.6; } .v2-repair-view[data-repair-page="hardware"] { overflow: auto; @@ -1280,10 +1329,48 @@ .v2-repair-event-list strong { font-size: 9px; } .v2-repair-event-list span { color: #758587; font-size: 8px; } +@media (max-height: 820px) { + .console-v2[data-module="self-repair"] .v2-repair-sidebar { + margin-top: 14px; + } + + .v2-repair-sidebar .v2-section-heading h2 { + margin-bottom: 10px; + } + + .v2-repair-sidebar > .v2-field-caption { + margin-bottom: 5px; + font-size: 11px; + } + + .v2-repair-view-switch button { + min-height: 48px; + grid-template-columns: 21px minmax(0, 1fr); + padding: 4px 9px; + } + + .v2-repair-view-switch button > svg { + width: 17px; + height: 17px; + } + + .v2-repair-view-switch strong { + font-size: 10px; + } + + .v2-repair-view-switch small { + font-size: 8px; + } + + .v2-repair-config-card { + min-height: 38px; + } +} + @media (max-width: 1220px) { .v2-repair-heading { grid-template-columns: minmax(0, 1fr); } .v2-repair-status-grid { display: none; } - .v2-repair-software-grid { grid-template-columns: minmax(260px, 1fr) minmax(175px, 0.65fr) minmax(310px, 1.2fr); } + .v2-repair-software-grid { grid-template-columns: minmax(260px, 1fr) minmax(190px, 0.68fr) minmax(330px, 1.25fr); } .v2-repair-toolbar button { padding-inline: 8px; } .v2-repair-toolbar button svg { display: none; } .v2-repair-hardware-grid { grid-template-columns: minmax(180px, 0.62fr) minmax(450px, 1.5fr) minmax(190px, 0.68fr); } @@ -1293,7 +1380,7 @@ .console-v2[data-module="self-repair"] .v2-self-repair { overflow: auto; } .v2-repair-view { min-height: 900px; overflow: visible; } .v2-repair-software-grid { - grid-template-areas: "twin cases" "timeline timeline"; + grid-template-areas: "twin cases" "logs logs"; grid-template-columns: minmax(330px, 1fr) 210px; grid-template-rows: 410px 390px; } diff --git a/apps/web-console/self-repair.js b/apps/web-console/self-repair.js index 0809e65..37bb104 100644 --- a/apps/web-console/self-repair.js +++ b/apps/web-console/self-repair.js @@ -4,16 +4,6 @@ const API_ROOT = "/api/self-repair"; const VIEW_KEY = "cognitive-agent.self-repair-view"; const MODE_KEY = "cognitive-agent.self-repair-execution"; - const PHASE_INDEX = { - IDLE: -1, - BASELINE: 0, - INJECTED: 1, - DIAGNOSED: 2, - REPAIRING: 3, - VERIFYING: 4, - PASSED: 5, - FAILED: 2, - }; const STATE_TEXT = { OK: "正常", WARN: "告警", @@ -23,13 +13,67 @@ NOT_USED: "未启用", IDLE: "检测中", }; - const TIMELINE = [ - { title: "开始正常任务", phases: ["BASELINE"] }, - { title: "故障发生并停止", phases: ["INJECTED"] }, - { title: "AI判断原因", phases: ["DIAGNOSED"] }, - { title: "执行恢复策略", phases: ["REPAIRING"] }, - { title: "继续原任务", phases: ["VERIFYING", "PASSED"] }, - ]; + const EVENT_META = { + BASELINE: { kind: "system", label: "运行" }, + INJECTED: { kind: "fault", label: "注入" }, + DIAGNOSED: { kind: "diagnosis", label: "诊断" }, + REPAIRING: { kind: "repair", label: "修复" }, + VERIFYING: { kind: "repair", label: "复测" }, + PASSED: { kind: "success", label: "恢复" }, + FAILED: { kind: "fault", label: "失败" }, + }; + const FALLBACK_SCENARIOS = [ + ["FI-FIELD-01", "位置标定丢失", "座椅移动 / 位置记忆"], + ["FI-FIELD-02", "位置记录CRC回退", "四轴位置 / SMV8持久化"], + ["FI-FIELD-03", "瞬态过载误判", "电机移动 / 防夹保护"], + ["FI-FIELD-04", "热管理唤醒失败", "通风 / 加热"], + ["FI-FIELD-05", "风扇关闭异常", "座椅通风"], + ["FI-FIELD-06", "WDT复位后失能", "座椅控制器 / 多功能协同"], + ].map(([id, shortTitle, system]) => ({ + id, + short_title: shortTitle, + system, + normal_task: "等待连接后加载正常任务", + fault_reaction: "等待连接后加载故障停止状态", + diagnosis: "等待连接后加载 AI 诊断", + repair: "等待连接后加载恢复策略", + recovery_reaction: "等待连接后加载恢复结果", + field_basis: "自修复服务连接后显示实时用例说明", + })); + + function fallbackState(message = "正在连接自修复服务") { + return { + scenarios: FALLBACK_SCENARIOS, + healing: { + phase: "IDLE", + phase_text: "等待服务连接", + elapsed_s: 0, + case_id: null, + telemetry: {}, + events: [], + }, + batch: { active: false, completed: 0, total: 6, passed: 0, failed: 0, results: [] }, + runtime: { + active: false, + execution_mode: "shadow", + started_epoch: null, + stopped_epoch: null, + elapsed_s: 0, + healthy_elapsed_s: 0, + motion_profile: "四电机协同自由运动", + cycle_count: 0, + injected_count: 0, + repaired_count: 0, + events: [], + }, + bridge: { online: false, protocol_online: false, bus_traffic: false }, + bridge_manager: { state: "STARTING", message }, + integration: { can_runtime_configured: false, firmware_tools_configured: false }, + hardware: { groups: [], connectors: {} }, + hardware_session: { active: false, events: [] }, + bench_twin: {}, + }; + } const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; @@ -39,7 +83,7 @@ .replaceAll(">", ">") .replaceAll('"', """); - let latest = null; + let latest = fallbackState(); let selectedCaseId = null; let selectedPinId = "JC1-1"; let activeView = sessionStorage.getItem(VIEW_KEY) === "hardware" @@ -51,6 +95,7 @@ let polling = false; let toastTimer = 0; let confirmationResolve = null; + let eventFilter = "all"; function isAdmin() { return window.CognitiveAgentConsoleV2?.getRole() === "admin" @@ -118,7 +163,7 @@ root.setAttribute("aria-label", "座椅 AI 自修复验证台"); root.innerHTML = `
-
COGNITIVE SEAT / SELF-REPAIR

座椅AI自修复验证台

量产软件故障闭环恢复 · 开发板接口在线诊断

+
SELF-REPAIR

座椅 AI 自修复验证

座椅持续自由运行 · 故障独立注入 · 全过程事件留痕

CAN桥接离线
座椅ECU未检测
@@ -128,22 +173,15 @@
-
01

软件自修复

正常任务 → 故障停止 → AI 判断 → 安全修复 → 继续原目标

+
01

连续运行与自修复验证

运行任务与故障注入相互独立;选择用例不会启动或重置座椅

- - - + +
-
-
PRODUCTION CASES

量产故障用例

0 / 6
-
-

等待启动批量验证

-
-
DIGITAL TWIN

座椅3D孪生

正常基线
@@ -153,9 +191,31 @@
影子动作仿真等待启动座椅任务待机

实际动作将同步显示在座椅模型

-
-
HEALING FLOW

座椅任务自修复

0.000 s
-
+
+
FAULT INJECTION

量产故障注入

6 个用例
+

先启动座椅持续运行,再按需选择故障。注入事件会明确标记为管理员操作。

+
+
+ 当前选择:— + + 本次尚未注入故障 +
+
+
+
EVENT STREAM

运行与自修复日志

等待启动
+
+
本次连续运行00:00:00
+
累计健康运行00:00:00
+
故障 / 已修复0 / 0
+
+
+
+ + +
+ 自动跟随最新日志 +
+
@@ -240,36 +300,92 @@ || latest.scenarios[0]; } - function timelineDetail(index, scenario, healing) { - const telemetry = healing.telemetry || {}; - const details = [ - scenario.normal_task, - telemetry.bench_reaction || scenario.fault_reaction, - scenario.diagnosis, - scenario.repair, - scenario.recovery_reaction, - ]; - const phases = TIMELINE[index].phases; - const event = [...(healing.events || [])].reverse() - .find((item) => phases.includes(item.phase)); - return event?.detail || event?.title || details[index] || "等待运行日志"; + function formatDuration(seconds) { + const whole = Math.max(0, Math.floor(Number(seconds || 0))); + const hours = Math.floor(whole / 3600); + const minutes = Math.floor((whole % 3600) / 60); + const remainder = whole % 60; + return [hours, minutes, remainder] + .map((value) => String(value).padStart(2, "0")) + .join(":"); } - function renderTimeline() { - const healing = latest.healing; - const scenario = scenarioForDisplay(); - if (!scenario) return; - const phaseIndex = PHASE_INDEX[healing.phase] ?? -1; - const passed = healing.phase === "PASSED"; + function formatEventTime(epoch) { + if (!epoch) return "--:--:--.---"; + const value = new Date(Number(epoch) * 1000); + const base = [value.getHours(), value.getMinutes(), value.getSeconds()] + .map((item) => String(item).padStart(2, "0")) + .join(":"); + return `${base}.${String(value.getMilliseconds()).padStart(3, "0")}`; + } + + function runtimeState() { + return latest.runtime || fallbackState().runtime; + } + + function renderEventStream() { + const runtime = runtimeState(); + const healing = latest.healing || {}; + const scenario = (latest.scenarios || []).find((item) => item.id === healing.case_id) + || scenarioForDisplay(); + const runtimeEvents = (runtime.events || []).map((event) => ({ + ...event, + kind: event.kind || "system", + label: event.label || "运行", + })); + const healingEvents = (healing.events || []).map((event) => { + const meta = EVENT_META[event.phase] || EVENT_META.BASELINE; + const injected = event.phase === "INJECTED"; + return { + ...event, + kind: meta.kind, + label: meta.label, + title: injected + ? `管理员注入「${scenario?.short_title || "故障用例"}」` + : event.title, + tags: [ + injected ? "来源:admin" : null, + healing.run_id ? `事件:${healing.run_id}` : null, + healing.diagnosed_code && ["DIAGNOSED", "REPAIRING", "VERIFYING", "PASSED", "FAILED"].includes(event.phase) + ? `诊断码:${healing.diagnosed_code}` : null, + ].filter(Boolean), + }; + }); + const activeRepair = ["INJECTED", "DIAGNOSED", "REPAIRING", "VERIFYING"].includes(healing.phase); + const twin = latest.bench_twin || {}; + const naturalFault = runtime.active && !activeRepair && ["IDLE", "PASSED"].includes(healing.phase) + && Number(twin.system?.faulted_mask || 0) !== 0; + const liveEvents = runtime.active && !activeRepair ? [{ + epoch: latest.generated_epoch, + time: formatEventTime(latest.generated_epoch), + kind: naturalFault ? "fault" : "heartbeat", + label: naturalFault ? "自然故障" : "运行心跳", + title: naturalFault + ? "运行中检测到座椅异常" + : `座椅已正常运行 ${formatDuration(runtime.healthy_elapsed_s)}`, + detail: naturalFault + ? `ECU FaultedMask=0x${Number(twin.system.faulted_mask).toString(16).toUpperCase()},AI正在持续监测。` + : `${runtime.motion_profile} · 已完成 ${runtime.cycle_count || 0} 个动作周期。`, + tags: (twin.axes?.length ? twin.axes : [35, 42, 48, 20].map((position_pct, motor) => ({ motor, position_pct }))) + .map((axis) => `M${axis.motor} ${Number(axis.position_pct || 0).toFixed(0)}%`), + }] : []; + const events = [...runtimeEvents, ...healingEvents, ...liveEvents] + .filter((event) => eventFilter === "all" || ["fault", "diagnosis", "repair", "success"].includes(event.kind)) + .sort((left, right) => Number(left.epoch || 0) - Number(right.epoch || 0)); + + $("#repairEventStream").innerHTML = events.length + ? events.map((event) => `
${esc(event.label)}${esc(event.title)}

${esc(event.detail || "状态已更新")}

${event.tags?.length ? `
${event.tags.map((tag) => `${esc(tag)}`).join("")}
` : ""}
`).join("") + : `
${runtime.active ? "座椅正在正常运行" : "等待启动座椅运行"}${runtime.active ? "运行心跳、故障、诊断和修复事件将在这里持续追加。" : "启动后将记录运行时长、四轴状态和后续故障注入。"}
`; + $("#repairEventStream").scrollTop = $("#repairEventStream").scrollHeight; + + $("#repairRuntimeElapsed").textContent = formatDuration(runtime.elapsed_s); + $("#repairHealthyElapsed").textContent = formatDuration(runtime.healthy_elapsed_s); + $("#repairFaultSummary").textContent = `${Number(runtime.injected_count || 0)} / ${Number(runtime.repaired_count || 0)}`; const failed = healing.phase === "FAILED"; - $("#repairTimeline").innerHTML = TIMELINE.map((stage, index) => { - let state = "pending"; - if (passed || phaseIndex > index) state = "complete"; - else if (phaseIndex === index && healing.phase !== "IDLE") state = failed ? "failed" : "active"; - const label = state === "complete" ? "已完成" : state === "active" ? "进行中" : state === "failed" ? "失败" : "等待"; - return `
${esc(stage.title)}${label}

${esc(timelineDetail(index, scenario, healing))}

`; - }).join(""); - $("#repairElapsed").textContent = `${Number(healing.elapsed_s || 0).toFixed(3)} s`; + const repairing = activeRepair; + const logState = $("#repairLogState"); + logState.textContent = failed ? "需要处理" : repairing ? "自修复中" : healing.phase === "PASSED" && runtime.active ? "已恢复运行" : runtime.active ? "健康运行" : "等待启动"; + logState.dataset.state = failed ? "fault" : repairing ? "repairing" : runtime.active ? "healthy" : "idle"; } function renderCases() { @@ -290,26 +406,27 @@ if (latest.batch?.active) return; selectedCaseId = button.dataset.repairCase; renderCases(); - renderTimeline(); renderTwin(); }); }); - const batch = latest.batch || {}; - const completed = Number(batch.completed || 0); - const total = Number(batch.total || 6); - $("#repairBatchProgress").style.width = `${Math.min(100, (completed / Math.max(1, total)) * 100)}%`; - $("#repairBatchCount").textContent = `${completed} / ${total}`; - $("#repairBatchSummary").textContent = batch.active - ? `正在验证 ${batch.current_case_id || "下一项"} · 通过 ${batch.passed} · 失败 ${batch.failed}` - : completed - ? `批量验证完成 · 通过 ${batch.passed} · 失败 ${batch.failed}` - : "等待启动批量验证"; + const selected = scenarioForDisplay(); + const runtime = runtimeState(); + $("#repairCaseCount").textContent = `${(latest.scenarios || []).length} 个用例`; + $("#repairSelectedCase").textContent = selected + ? `当前选择:${selected.short_title}` : "当前选择:—"; + $("#repairInjectionCount").textContent = Number(runtime.injected_count || 0) + ? `本次已注入 ${runtime.injected_count} 次 · 已修复 ${runtime.repaired_count || 0} 次` + : "本次尚未注入故障"; + const activeRepair = !["IDLE", "PASSED", "FAILED"].includes(latest.healing?.phase); + $("#repairInject").disabled = !runtime.active || activeRepair || latest.batch?.active; + $("#repairInject").innerHTML = `${activeRepair ? "自修复处理中" : runtime.active ? "注入所选故障" : "请先启动座椅运行"}`; } function renderTwin() { const healing = latest.healing; const scenario = scenarioForDisplay(); if (!scenario) return; + const runtime = runtimeState(); const telemetry = healing.telemetry || {}; const live = latest.bench_twin || {}; const bench = executionMode === "bench"; @@ -337,11 +454,19 @@ ? (live.online ? "实物 CAN 同步" : "实物 CAN 暂离线") : "影子动作仿真"; $("#repairTwinTask").textContent = healing.phase === "IDLE" - ? `等待启动:${scenario.normal_task}` : scenario.normal_task; - $("#repairTwinStage").textContent = stage || "待机"; - $("#repairTwinState").textContent = healing.phase_text; + ? (runtime.active ? runtime.motion_profile : "等待启动座椅运行") + : scenario.normal_task; + $("#repairTwinStage").textContent = healing.phase === "IDLE" && runtime.active + ? "自由运动" : stage || "待机"; + $("#repairTwinState").textContent = healing.phase === "IDLE" + ? (runtime.active ? "持续运行中" : "等待启动") + : healing.phase === "PASSED" && runtime.active ? "已恢复运行" : healing.phase_text; $("#repairTwinReaction").textContent = telemetry.bench_reaction - || (healing.phase === "IDLE" ? scenario.field_basis : scenario.fault_reaction); + || (healing.phase === "IDLE" + ? (runtime.active + ? `四电机按设定轨迹协同运动 · 已完成 ${runtime.cycle_count || 0} 个周期` + : "实际动作将同步显示在座椅模型") + : scenario.fault_reaction); $("#repairAxisStrip").innerHTML = axes.map((axis) => { const position = Math.max(0, Math.min(100, Number(axis.position_pct || 0))); return `M${axis.motor}${position.toFixed(0)}%`; @@ -441,6 +566,7 @@ const healing = latest.healing || {}; const hardwarePhase = latest.hardware_session?.progress?.phase; const integration = latest.integration || {}; + const runtime = runtimeState(); $("#repairCanStatus").textContent = bridge.online ? "在线" : "离线"; $("#repairCanDot").dataset.state = bridge.online ? "online" : manager.state === "STARTING" ? "working" : "offline"; $("#repairProtocolStatus").textContent = bridge.protocol_online ? "协议在线" : "未检测"; @@ -453,9 +579,13 @@ }[hardwarePhase]; const aiState = activeView === "hardware" && hardwareState ? hardwareState - : [healing.phase_text || "等待验证", healing.phase === "PASSED" - ? "online" - : healing.phase === "IDLE" ? "offline" : "working"]; + : runtime.active && healing.phase === "IDLE" + ? ["持续监控", "online"] + : runtime.active && healing.phase === "PASSED" + ? ["已恢复监控", "online"] + : [healing.phase_text || "等待验证", healing.phase === "PASSED" + ? "online" + : healing.phase === "IDLE" ? "offline" : "working"]; $("#repairAiStatus").textContent = aiState[0]; $("#repairAiDot").dataset.state = aiState[1]; $("#repairConnectionText").textContent = bridge.online ? "CAN 桥接在线" : manager.message || "CAN 桥接离线"; @@ -472,6 +602,14 @@ ? "真实台架动作受本机 admin、安全确认与桥接状态约束" : "影子仿真不会写入 ECU 或驱动实体设备"; $("#repairFirmwareFlash").disabled = !integration.firmware_tools_configured; + const runtimeToggle = $("#repairRuntimeToggle"); + const repairActive = ["INJECTED", "DIAGNOSED", "REPAIRING", "VERIFYING"].includes(healing.phase); + runtimeToggle.innerHTML = runtime.active + ? '停止座椅运行' + : '启动座椅运行'; + runtimeToggle.classList.toggle("danger", runtime.active); + runtimeToggle.classList.toggle("primary", !runtime.active); + runtimeToggle.disabled = repairActive; window.CognitiveAgentConsoleV2?.enhanceSelect($("#repairExecutionMode")); } @@ -484,7 +622,7 @@ function renderAll() { renderGlobal(); renderCases(); - renderTimeline(); + renderEventStream(); renderTwin(); renderHardware(); renderRecords(); @@ -550,20 +688,33 @@ sessionStorage.setItem(MODE_KEY, executionMode); if (latest) renderAll(); }); - $("#repairStart").addEventListener("click", async () => { - if (executionMode === "bench" && !(await confirmAction("即将向实物 ECU 写入受控故障并驱动座椅恢复。请确认运动区域无人、无遮挡且急停可用。"))) return; - runAction(() => apiPost("/healing/start", { case_id: selectedCaseId, execution_mode: executionMode }), executionMode === "bench" ? "实物自修复用例已启动" : "影子故障已注入,开始自修复"); - }); - $("#repairRunAll").addEventListener("click", async () => { + $("#repairRuntimeToggle").addEventListener("click", async () => { + const active = Boolean(runtimeState().active); if (executionMode === "bench" && !(await confirmAction( - "将依次执行6个实物故障用例;每项开始前会自动停止输出并完成四轴归零。请确认运动区域无人、无遮挡且急停可用。", - "确认实物批量验证", + active + ? "即将停止实物座椅连续运行。请确认可安全停止。" + : "即将启动实物座椅四电机协同自由运动。请确认运动区域无人、无遮挡且急停可用。", + active ? "确认停止" : "确认启动", ))) return; - runAction(() => apiPost("/healing/run-all", { execution_mode: executionMode }), "六项自动验证已启动"); + runAction( + () => apiPost("/runtime", { active: !active, execution_mode: executionMode }), + active ? "座椅连续运行已停止" : "座椅连续运行已启动", + ); + }); + $("#repairInject").addEventListener("click", async () => { + const scenario = scenarioForDisplay(); + if (!runtimeState().active || !scenario) return; + if (executionMode === "bench" && !(await confirmAction( + `即将向实物 ECU 注入「${scenario.short_title}」并驱动座椅自修复。请确认运动区域无人、无遮挡且急停可用。`, + "确认注入", + ))) return; + runAction( + () => apiPost("/healing/start", { case_id: selectedCaseId, execution_mode: executionMode }), + executionMode === "bench" ? "实物故障已注入" : "影子故障已注入,开始自修复", + ); }); $("#repairReset").addEventListener("click", async () => { - if (executionMode === "bench" && !(await confirmAction("四轴将依次回到机械零位。请确认运动区域无人、无遮挡且急停可用。", "确认归零"))) return; - runAction(() => apiPost("/healing/reset", { execution_mode: executionMode }), executionMode === "bench" ? "四轴归零请求已提交" : "自修复验证已重置"); + runAction(() => apiPost("/runtime/reset-statistics"), "本次运行统计已重置"); }); $("#repairFirmwareFlash").addEventListener("click", async () => { if (!(await confirmAction("即将构建并烧录台架测试固件。请确认座椅运动区域无人、急停可用且 CMSIS-DAP 已连接。", "确认构建/烧录"))) return; @@ -594,6 +745,12 @@ }); $("#repairSoftwareRecords").addEventListener("click", () => $("#repairSoftwareDialog").showModal()); $("#repairHardwareRecords").addEventListener("click", () => $("#repairHardwareDialog").showModal()); + $$('[data-repair-log-filter]').forEach((button) => button.addEventListener("click", () => { + eventFilter = button.dataset.repairLogFilter === "fault" ? "fault" : "all"; + $$('[data-repair-log-filter]').forEach((item) => item.classList.toggle("active", item === button)); + renderEventStream(); + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + })); $$('[data-repair-close]').forEach((button) => button.addEventListener("click", () => $(`#${button.dataset.repairClose}`).close())); window.addEventListener("cognitive-agent:module-change", (event) => { if (event.detail?.module === "self-repair") poll(); @@ -607,6 +764,7 @@ if (!buildShell()) return; bindEvents(); showView(activeView); + renderAll(); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); if (isActive()) poll(); window.setInterval(poll, 600); diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index 5e2da91..4428d8b 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "cognitive-agent-pad-v80"; +const CACHE_VERSION = "cognitive-agent-pad-v115"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ @@ -6,19 +6,18 @@ const APP_SHELL = [ "/index.html", "/styles.css?v=dual-seat-9", "/network.js?v=android-pad-1", - "/app.js?v=dual-seat-67", + "/app.js?v=dual-seat-80", "/gesture-control.js?v=android-pad-7", "/pwa.js?v=android-pad-1", - "/console-v2.css?v=desktop-refactor-87", - "/console-v2.js?v=desktop-refactor-28", - "/self-extension.css?v=extension-12", - "/self-extension.js?v=extension-18", - "/self-calibration.css?v=calibration-6", - "/self-calibration.js?v=calibration-3", - "/self-repair.css?v=repair-17", - "/self-repair.js?v=repair-17", - "/self-repair-seat3d.js?v=repair-17", - "/assets/self-calibration-model.bundle.js", + "/console-v2.css?v=desktop-refactor-106", + "/console-v2.js?v=desktop-refactor-34", + "/self-extension.css?v=extension-18", + "/self-extension.js?v=extension-20", + "/self-calibration.css?v=calibration-10", + "/self-calibration.js?v=calibration-6", + "/self-repair.css?v=repair-21", + "/self-repair.js?v=repair-20", + "/self-repair-seat3d.js?v=repair-18", "/assets/models/n7_seat.glb", "/manifest.webmanifest", "/icons/app-icon-192.png", diff --git a/src/cognitive_agent/application/self_repair/service.py b/src/cognitive_agent/application/self_repair/service.py index 1f98d83..e6f8384 100644 --- a/src/cognitive_agent/application/self_repair/service.py +++ b/src/cognitive_agent/application/self_repair/service.py @@ -260,6 +260,7 @@ class ConsoleState(object): self.latest_report_path = None self.batch = self._new_batch_state() self.batch_thread = None + self.runtime = self._new_runtime_state() self.hardware_active = False self.hardware_run_id = None self.hardware_started_epoch = None @@ -535,6 +536,202 @@ class ConsoleState(object): "finished_epoch": None, "results": [], } + @staticmethod + def _new_runtime_state(execution_mode="shadow"): + return { + "active": False, + "execution_mode": execution_mode, + "started_epoch": None, + "stopped_epoch": None, + "healthy_started_epoch": None, + "healthy_accumulated_s": 0.0, + "motion_profile": "四电机协同自由运动", + "cycle_count": 0, + "injected_count": 0, + "repaired_count": 0, + "last_healing_run_id": None, + "events": [], + } + + @staticmethod + def _runtime_event(kind, label, title, detail, now): + return { + "kind": kind, + "label": label, + "title": title, + "detail": detail, + "epoch": now, + "time": time.strftime("%H:%M:%S", time.localtime(now)) + + (".%03d" % int((now * 1000) % 1000)), + } + + def _runtime_snapshot(self, healing, now): + runtime = dict(self.runtime) + elapsed = 0.0 + if runtime.get("started_epoch") is not None: + end = now if runtime.get("active") else ( + runtime.get("stopped_epoch") or now) + elapsed = max(0.0, end - float(runtime["started_epoch"])) + healthy = float(runtime.get("healthy_accumulated_s") or 0.0) + if runtime.get("active") and runtime.get("healthy_started_epoch") is not None: + healthy += max( + 0.0, now - float(runtime["healthy_started_epoch"])) + + run_id = healing.get("run_id") + phase = healing.get("phase") + if run_id and run_id != runtime.get("last_healing_run_id"): + self.runtime["last_healing_run_id"] = run_id + self.runtime["injected_count"] += 1 + self.runtime["healthy_accumulated_s"] = healthy + self.runtime["healthy_started_epoch"] = None + runtime.update(self.runtime) + if phase == PHASE_PASSED and run_id and \ + run_id not in self.runtime.get("repaired_run_ids", []): + repaired = list(self.runtime.get("repaired_run_ids", [])) + repaired.append(run_id) + self.runtime["repaired_run_ids"] = repaired[-100:] + self.runtime["repaired_count"] += 1 + if self.runtime.get("active"): + self.runtime["healthy_started_epoch"] = now + if self.runtime.get("execution_mode") == "bench": + request_id = "SELF_REPAIR_RUNTIME_RESUME_%d" % int( + now * 1000) + _atomic_json( + PENDING_DIR / (request_id + ".json"), + self._dynamic_runtime_request(request_id, True)) + runtime.update(self.runtime) + if phase == PHASE_FAILED and run_id and \ + run_id not in self.runtime.get("failed_run_ids", []): + failed = list(self.runtime.get("failed_run_ids", [])) + failed.append(run_id) + self.runtime["failed_run_ids"] = failed[-100:] + self.runtime.update({ + "active": False, + "stopped_epoch": now, + "healthy_started_epoch": None, + }) + self.runtime["events"].append(self._runtime_event( + "fault", "安全停止", "自修复未通过,连续运行已停止", + "座椅保持安全停止,请检查故障证据后重新启动。", now)) + runtime.update(self.runtime) + healthy = float(self.runtime.get("healthy_accumulated_s") or 0.0) + if self.runtime.get("active") and self.runtime.get( + "healthy_started_epoch") is not None: + healthy += max( + 0.0, now - float(self.runtime["healthy_started_epoch"])) + runtime["elapsed_s"] = round(elapsed, 3) + runtime["healthy_elapsed_s"] = round(healthy, 3) + runtime["cycle_count"] = int(elapsed // 20) + runtime.pop("healthy_started_epoch", None) + runtime.pop("healthy_accumulated_s", None) + runtime.pop("repaired_run_ids", None) + runtime.pop("failed_run_ids", None) + return runtime + + @staticmethod + def _dynamic_runtime_request(request_id, start): + opcode = 0x0E if start else 0x0F + mode_id = 1 if start else 0 + capability = ("seat.start_dynamic_mode" if start else + "seat.stop_dynamic_mode") + operation_id = "self_repair_runtime_dynamic" + return { + "schema": "seat-function-request-v1", + "request_id": request_id, + "created_epoch": time.time(), + "armed_until_epoch": time.time() + 60.0, + "source": "seat_self_healing_console", + "action": "execute_command_plan", + "payload": { + "operations": [{ + "id": operation_id, + "domain": "seat", + "capability": capability, + "parameters": ({"mode_id": mode_id} if start else {}), + }], + "commands": [{ + "id": "runtime_dynamic_start" if start else + "runtime_dynamic_stop", + "operation_ref": operation_id, + "frame_id": 0x300, + "message": "T2G_Seat_Move_Command_300", + "data": [opcode, 0x10, 0, 0, mode_id, 0, 0, 0], + "signals": { + "Opcode": opcode, + "Motor_Selector": 0, + "Direction": 0, + "Protocol_Version": 1, + "Sequence": 0, + "Param_A": mode_id, + "Param_B": 0, + }, + "completion": { + "value": mode_id, + "accepted_states": [1, 2], + "timeout_ms": 8000, + }, + }], + }, + } + + def set_runtime(self, active, execution_mode="shadow"): + if execution_mode not in ("shadow", "bench"): + raise ValueError("未知运行方式") + with self.lock: + now = time.time() + if bool(self.runtime.get("active")) == bool(active): + return self._runtime_snapshot(self.engine.snapshot(now), now) + if active and (self.hardware_active or self.batch.get("active")): + raise ValueError("硬件诊断或批量验证正在运行,不能启动座椅连续运行") + if active and self.engine.phase not in ("IDLE", PHASE_PASSED, + PHASE_FAILED): + raise ValueError("自修复正在处理故障,请等待完成后再启动连续运行") + if execution_mode == "bench": + self.bridge_manager.ensure_async() + _, bridge = self._wait_bridge( + require_protocol=True, timeout_s=12.0) + if not bridge.get("online") or not bridge.get("protocol_online"): + raise ValueError("CAN桥接或座椅ECU协议未在线,不能控制实物座椅") + request_id = "SELF_REPAIR_RUNTIME_%s_%d" % ( + "START" if active else "STOP", int(now * 1000)) + _atomic_json( + PENDING_DIR / (request_id + ".json"), + self._dynamic_runtime_request(request_id, bool(active))) + if active: + self.engine.reset() + self.runtime = self._new_runtime_state(execution_mode) + self.runtime.update({ + "active": True, + "started_epoch": now, + "healthy_started_epoch": now, + }) + self.runtime["events"].append(self._runtime_event( + "system", "运行", "座椅自由运动任务已启动", + "四个电机进入协同自由运动,AI开始持续监测。", now)) + else: + healthy_started = self.runtime.get("healthy_started_epoch") + if healthy_started is not None: + self.runtime["healthy_accumulated_s"] += max( + 0.0, now - float(healthy_started)) + self.runtime.update({ + "active": False, + "stopped_epoch": now, + "healthy_started_epoch": None, + }) + self.runtime["events"].append(self._runtime_event( + "system", "运行", "座椅自由运动任务已停止", + "管理员停止了连续运行,故障注入入口已关闭。", now)) + return self._runtime_snapshot(self.engine.snapshot(now), now) + + def reset_runtime_statistics(self): + with self.lock: + if self.runtime.get("active"): + raise ValueError("请先停止座椅运行,再重置本次统计") + self.engine.reset() + mode = self.runtime.get("execution_mode", "shadow") + self.runtime = self._new_runtime_state(mode) + return self._runtime_snapshot(self.engine.snapshot(), time.time()) + @staticmethod def _bridge_summary(status, now): heartbeat = None if not isinstance(status, dict) else status.get( @@ -944,6 +1141,7 @@ class ConsoleState(object): hardware = self._hardware_snapshot(status, now) bridge = self._bridge_summary(status, now) healing = self.engine.snapshot(now) + runtime = self._runtime_snapshot(healing, now) self._save_completed_report(healing, hardware, bridge) return { "schema": "seat-self-healing-console-state-v1", @@ -959,6 +1157,7 @@ class ConsoleState(object): "bench_twin": self._bench_twin_snapshot(status, now), "bridge_manager": self.bridge_manager.snapshot(), "healing": healing, + "runtime": runtime, "bench_reset": dict(self.bench_reset), "hardware": hardware, "batch": dict(self.batch), @@ -987,6 +1186,8 @@ class ConsoleState(object): def start(self, case_id, execution_mode="shadow", allow_batch=False): with self.lock: + if not self.runtime.get("active") and not allow_batch: + raise ValueError("请先启动座椅连续运行,再注入故障") if execution_mode == "bench": if (self.hardware_active or (self.batch.get("active") and not allow_batch)): @@ -1006,10 +1207,15 @@ class ConsoleState(object): raise ValueError("未识别到本工程座椅ECU协议,不能执行台架故障注入") self._sync_bench_reset(status, now) zero = self._bench_zero_snapshot(status) - if (self.bench_reset.get("phase") != "PASSED" or - not zero.get("all_zero")): + system = (status or {}).get("system") or {} + runtime_ready = ( + self.runtime.get("active") and + int(system.get("position_valid_mask", 0) or 0) == 0x0F and + int(system.get("faulted_mask", 0) or 0) == 0) + if ((self.bench_reset.get("phase") != "PASSED" or + not zero.get("all_zero")) and not runtime_ready): raise ValueError( - "请先点击‘重置并四轴归零’,确认四轴均为零位后再启动该用例") + "连续运行状态无可信四轴位置,请先停止运行并完成四轴归零") request_id = "SELF_HEAL_BENCH_%d" % int(now * 1000) request = { "schema": "seat-function-request-v1", @@ -1026,11 +1232,12 @@ class ConsoleState(object): case_id, now=now, execution_mode="bench") self.engine.telemetry["bench_request_id"] = request_id _atomic_json(PENDING_DIR / (request_id + ".json"), request) - self.bench_reset.update({ - "phase": "CONSUMED", - "summary": "本次归零许可已用于%s;下一个用例前须重新归零" % - case_id, - }) + if not runtime_ready: + self.bench_reset.update({ + "phase": "CONSUMED", + "summary": "本次归零许可已用于%s;下一个用例前须重新归零" % + case_id, + }) return value self.latest_report_path = None self.bench_request_id = None @@ -1317,6 +1524,21 @@ class ConsoleState(object): return value def emergency_stop(self): + with self.lock: + if self.runtime.get("active"): + now = time.time() + healthy_started = self.runtime.get("healthy_started_epoch") + if healthy_started is not None: + self.runtime["healthy_accumulated_s"] += max( + 0.0, now - float(healthy_started)) + self.runtime.update({ + "active": False, + "stopped_epoch": now, + "healthy_started_epoch": None, + }) + self.runtime["events"].append(self._runtime_event( + "fault", "停止", "安全链路触发紧急停止", + "STOP ALL 与热管理关闭请求已提交。", now)) request_id = "SELF_HEAL_STOP_%d" % int(time.time() * 1000) value = { "schema": "seat-function-emergency-v1", @@ -1591,6 +1813,14 @@ class SelfRepairService(object): return self.state.start( payload.get("case_id"), payload.get("execution_mode", "shadow")) + def set_runtime(self, payload): + return self.state.set_runtime( + bool(payload.get("active")), + payload.get("execution_mode", "shadow")) + + def reset_runtime_statistics(self): + return self.state.reset_runtime_statistics() + def start_batch(self, payload): return self.state.start_batch(payload.get("execution_mode", "shadow")) diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index ab86ba7..81c129d 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -573,6 +573,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): path in physical_paths or ( path in { + "/api/self-repair/runtime", "/api/self-repair/healing/start", "/api/self-repair/healing/reset", } @@ -584,6 +585,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler): if path == "/api/self-repair/healing/start": self._json({"ok": True, "healing": self.repair.start_healing(payload)}) return + if path == "/api/self-repair/runtime": + self._json({"ok": True, "runtime": self.repair.set_runtime(payload)}) + return + if path == "/api/self-repair/runtime/reset-statistics": + self._json({ + "ok": True, + "runtime": self.repair.reset_runtime_statistics(), + }) + return if path == "/api/self-repair/healing/run-all": self._json({"ok": True, "batch": self.repair.start_batch(payload)}) return diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 6bcc4c2..f2b4575 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -177,28 +177,28 @@ operations: def test_static_console_and_runtime_endpoint(self) -> None: with self.opener.open(f"{self.base_url}/", timeout=3) as response: html = response.read().decode("utf-8") - with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-67", timeout=3) as response: + with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-80", timeout=3) as response: app_script = response.read().decode("utf-8") with self.opener.open( f"{self.base_url}/gesture-control.js?v=android-pad-7", timeout=3 ) as response: gesture_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/console-v2.js?v=desktop-refactor-28", timeout=3 + f"{self.base_url}/console-v2.js?v=desktop-refactor-34", timeout=3 ) as response: console_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/console-v2.css?v=desktop-refactor-87", timeout=3 + f"{self.base_url}/console-v2.css?v=desktop-refactor-106", timeout=3 ) as response: console_styles = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/self-extension.js?v=extension-18", timeout=3 + f"{self.base_url}/self-extension.js?v=extension-20", timeout=3 ) as response: extension_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/self-extension.css?v=extension-12", timeout=3 + f"{self.base_url}/self-extension.css?v=extension-18", timeout=3 ) as response: - extension_styles = response.read().decode("utf-8") + extension_styles = response.read().decode("utf-8").replace("\r\n", "\n") with self.opener.open( f"{self.base_url}/api/extension/health", timeout=3 ) as response: @@ -219,22 +219,39 @@ operations: self.assertIn("CMD 0x431 · FB 0x41A · CH1", html) self.assertIn("CMD 0x431 · FB 0x388 · CH1", html) self.assertNotIn("透明车体 · 实体设备", html) - self.assertIn('src="/app.js?v=dual-seat-67"', html) + self.assertIn('src="/app.js?v=dual-seat-80"', html) self.assertIn('href="/styles.css?v=dual-seat-9"', html) self.assertIn('src="/gesture-control.js?v=android-pad-7"', html) self.assertNotIn('class="vehicle-system-hud"', html) - self.assertIn('src="/console-v2.js?v=desktop-refactor-28"', html) - self.assertIn('href="/console-v2.css?v=desktop-refactor-87"', html) - self.assertIn('src="/self-extension.js?v=extension-18"', html) - self.assertIn('href="/self-extension.css?v=extension-12"', html) + self.assertIn('src="/console-v2.js?v=desktop-refactor-34"', html) + self.assertIn('href="/console-v2.css?v=desktop-refactor-106"', html) + self.assertIn('src="/self-extension.js?v=extension-20"', html) + self.assertIn('href="/self-extension.css?v=extension-18"', html) self.assertNotIn("滚动查看更多", html) self.assertIn('aria-label="执行车舱控制指令"', html) self.assertIn('sessionStorage.getItem("cognitive-agent.demo-role")', console_script) self.assertIn('id="v2SidebarToggle"', console_script) self.assertIn('id = "v2ModuleSwitcher"', console_script) + self.assertIn( + 'panelHeading.classList.add("v2-operation-heading", "v2-module-heading")', + console_script, + ) + self.assertIn('kicker.textContent = "SELF-ORCHESTRATION"', console_script) + self.assertIn( + 'if (ui.activeModule === "self-extension" && ui.role !== "user")', + console_script, + ) + self.assertIn( + 'if (ui.activeModule === "self-extension" && role !== "user" && !allowModuleTransition)', + console_script, + ) self.assertIn('label: "自编排", available: true', console_script) self.assertIn('id: "self-calibration"', console_script) self.assertIn('label: "自标定"', console_script) + self.assertLess( + console_script.index('label: "自标定"'), + console_script.index('label: "自编排"'), + ) self.assertIn("adminOnly: true", console_script) self.assertIn( "$('.v2-module-tab[data-module=\"self-calibration\"]')", @@ -245,6 +262,16 @@ operations: self.assertIn('Hi,user', console_script) self.assertIn('id: "self-repair"', console_script) self.assertIn('label: "自修复"', console_script) + self.assertIn('id: "self-evolution", label: "自进化", available: true', console_script) + self.assertIn( + 'evolution.setAttribute("aria-label", "自进化能力日志")', + console_script, + ) + self.assertIn('SELF-EVOLUTION', console_script) + self.assertIn('v2-evolution-heading v2-module-heading', console_script) + self.assertIn('data-evolution-index', console_script) + self.assertIn('NEXT EVOLUTION · v0.6', console_script) + self.assertIn('["self-orchestration", "self-extension", "self-evolution"]', console_script) self.assertIn('roleSelect.disabled = ui.activeModule === "self-repair"', console_script) self.assertNotIn('label: "自诊断"', console_script) self.assertIn('手势模型', console_script) @@ -262,7 +289,37 @@ operations: self.assertIn('grid-template-columns: clamp(245px, 18.5vw, 310px)', console_styles) self.assertIn('.v2-control-switch {', console_styles) self.assertIn('.v2-module-switcher {', console_styles) - self.assertIn('grid-template-columns: repeat(4, minmax(0, 1fr))', console_styles) + self.assertIn('grid-template-columns: repeat(5, minmax(0, 1fr))', console_styles) + self.assertIn('[data-module="self-evolution"] .v2-self-evolution', console_styles) + self.assertIn( + '.console-v2 .v2-module-heading.v2-module-heading h1', + console_styles, + ) + self.assertIn('font-size: clamp(24px, 2vw, 31px)', console_styles) + self.assertIn('--v2-module-title-block: clamp(12px, 2vh, 22px)', console_styles) + self.assertIn('--v2-module-title-inline: clamp(20px, 2.3vw, 34px)', console_styles) + self.assertIn( + 'grid-template-rows: 36px clamp(236px, 33dvh, 246px) minmax(0, 1fr)', + console_styles, + ) + self.assertIn( + '[data-module="self-calibration"] .v2-self-calibration', + console_styles, + ) + self.assertIn( + '[data-module="self-repair"] .v2-self-repair', + console_styles, + ) + self.assertIn('.v2-evolution-version.active', console_styles) + self.assertIn('[data-evolution-scope="admin"]', console_styles) + self.assertIn( + 'color-mix(in srgb, var(--v2-accent-soft) 86%, white)', + console_styles, + ) + self.assertNotIn( + '.cabin-channel-legend [data-channel="ch0"]', + console_styles, + ) self.assertIn('margin-inline: calc(0px - var(--v2-main-pad))', console_styles) self.assertIn('background: color-mix(in srgb, var(--v2-accent) 30%, var(--v2-accent-soft))', console_styles) self.assertIn('width: clamp(48px, 45%, 72px)', console_styles) @@ -333,8 +390,10 @@ operations: self.assertIn('root.id = "v2SelfExtension"', extension_script) self.assertIn('data-extension-mode="study"', extension_script) self.assertIn('data-extension-mode="control"', extension_script) - self.assertIn('外设玩具能力一览与学习', extension_script) - self.assertIn('说一句,联动外设玩具与车端设备', extension_script) + self.assertIn('外设玩具能力学习', extension_script) + self.assertIn('外设玩具与车端联动', extension_script) + self.assertGreaterEqual(extension_script.count('class="v2-module-heading"'), 2) + self.assertGreaterEqual(extension_script.count('SELF-EXTENSION'), 2) self.assertIn('id="extensionExecuteButton"', extension_script) self.assertIn('data-lucide="play"', extension_script) self.assertNotIn('id="extensionControlHealth"', extension_script) @@ -363,6 +422,10 @@ operations: self.assertIn('.v2-extension-twin-host > .twin-band {', extension_styles) self.assertIn('.v2-extension-type-card[data-extension-connected="true"]', extension_styles) self.assertIn('.v2-extension-type-card[data-extension-connected="false"]', extension_styles) + self.assertIn( + '.v2-extension-control\n.v2-extension-learned-action {\n --extension-card-color: var(--v2-accent);', + extension_styles, + ) self.assertIn('grid-template-columns: repeat(3, minmax(0, 1fr))', extension_styles) self.assertTrue(extension_health["success"]) self.assertIn(extension_health["transport"], {"tcp", "mqtt", "simulation"}) @@ -444,6 +507,45 @@ operations: self.assertIn('id="vehicleSpeed"', html) self.assertNotIn('data-domain-panel="control_link"', html) self.assertIn('id="cabinStateTitle">整车设备状态', html) + self.assertIn('id="cabinCarouselPrev"', app_script) + self.assertIn('id="cabinCarouselNext"', app_script) + self.assertIn('aria-label="左右浏览设备卡片"', app_script) + self.assertNotIn('class="cabin-category-tabs"', app_script) + self.assertIn('LIVE VEHICLE DIGITAL TWIN', html) + self.assertIn('.joint-twin-shell > .twin-band {', console_styles) + self.assertIn('grid-template-rows: 62px minmax(0, 1fr)', console_styles) + self.assertIn('grid-template-columns: repeat(2, minmax(0, 1fr))', console_styles) + self.assertIn('--cabin-cards-per-view: 2', console_styles) + self.assertIn('const cabinCardsPerView = () => 2', app_script) + self.assertIn('scroll-snap-type: x mandatory', console_styles) + self.assertIn('data-device-power-value', app_script) + self.assertIn('查看${name}功率详情', app_script) + self.assertIn('name: "车内灯光", measurable: false', app_script) + self.assertIn('.device-power-entry.unavailable', console_styles) + self.assertIn('powerDrawer.id = "devicePowerDrawer"', app_script) + self.assertIn('window.updateDevicePowerTelemetry = updatePowerTelemetry', app_script) + self.assertIn('["longitudinal_motor", "水平电机"]', app_script) + self.assertIn('["cushion_motor", "坐盆电机"]', app_script) + self.assertIn('["backrest_motor", "座椅靠背电机"]', app_script) + self.assertIn('["legrest_motor", "腿托电机"]', app_script) + self.assertIn('["ventilation_control", "通风控制"]', app_script) + self.assertIn('["heating_control", "加热控制"]', app_script) + self.assertIn('["compressor", "压缩机"]', app_script) + self.assertIn('["electronic_fan", "电子风扇"]', app_script) + self.assertIn('["blower", "鼓风机"]', app_script) + self.assertIn('["thermal_management_module", "空调热管理集成模块"]', app_script) + self.assertIn('["compressor_controller", "压缩机电控"]', app_script) + self.assertIn('["electronic_cooling_pump", "电控冷却水泵"]', app_script) + self.assertIn('["front_left_window", "左前车窗"]', app_script) + self.assertIn('["front_right_window", "右前车窗"]', app_script) + self.assertIn('["rear_left_window", "左后车窗"]', app_script) + self.assertIn('["rear_right_window", "右后车窗"]', app_script) + self.assertIn('["front_left_combination_lamp", "左前组合灯"]', app_script) + self.assertIn('["front_right_combination_lamp", "右前组合灯"]', app_script) + self.assertIn('name: "主驾座椅", measurable: true, components: []', app_script) + self.assertIn('name: "遮阳帘", measurable: true, components: []', app_script) + self.assertIn("仅支持设备总功率", app_script) + self.assertIn('.device-power-drawer {', console_styles) self.assertNotIn('class="roof-visual"', html) self.assertNotIn('class="ambient-visual"', html) self.assertIn('id="driverAirDirection"', html) @@ -536,11 +638,11 @@ operations: def test_self_extension_vehicle_success_contract_is_strict(self) -> None: with self.opener.open( - f"{self.base_url}/self-extension.js?v=extension-18", timeout=3 + f"{self.base_url}/self-extension.js?v=extension-20", timeout=3 ) as response: extension_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/app.js?v=dual-seat-67", timeout=3 + f"{self.base_url}/app.js?v=dual-seat-80", timeout=3 ) as response: app_script = response.read().decode("utf-8") diff --git a/tests/e2e/test_self_calibration_api.py b/tests/e2e/test_self_calibration_api.py index 52c3ada..4f9b7b0 100644 --- a/tests/e2e/test_self_calibration_api.py +++ b/tests/e2e/test_self_calibration_api.py @@ -126,17 +126,17 @@ class TestSelfCalibrationApi: with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response: html = response.read().decode("utf-8") with urllib.request.urlopen( - f"{self.base_url}/self-calibration.js?v=calibration-3", + f"{self.base_url}/self-calibration.js?v=calibration-6", timeout=3, ) as response: script = response.read().decode("utf-8") with urllib.request.urlopen( - f"{self.base_url}/self-calibration.css?v=calibration-6", + f"{self.base_url}/self-calibration.css?v=calibration-10", timeout=3, ) as response: styles = response.read().decode("utf-8") with urllib.request.urlopen( - f"{self.base_url}/console-v2.js?v=desktop-refactor-28", + f"{self.base_url}/console-v2.js?v=desktop-refactor-31", timeout=3, ) as response: console_script = response.read().decode("utf-8") @@ -148,24 +148,30 @@ class TestSelfCalibrationApi: content_type = response.headers["Content-Type"] content_security_policy = response.headers["Content-Security-Policy"] - assert 'href="/self-calibration.css?v=calibration-6"' in html - assert 'src="/self-calibration.js?v=calibration-3"' in html + assert 'href="/self-calibration.css?v=calibration-10"' in html + assert 'src="/self-calibration.js?v=calibration-6"' in html assert 'data-calibration-page="live"' in script assert 'data-calibration-view="live"' in script assert 'data-calibration-page="hall"' not in script assert 'data-calibration-page="stereo"' not in script - assert "座椅—双目联合标定" in script + assert "座椅与双目联合标定" in script + assert 'class="v2-module-heading"' in script + assert 'SELF-CALIBRATION' in script + assert 'padding: var(--v2-module-title-block) 0 0' in styles + assert 'margin-inline: var(--v2-module-title-inline)' in styles assert "实时座椅姿态" in script assert "四轴实时控制" in script assert "ZED 实时画面" in script - assert "双目位姿数据" in script + assert "双目位姿数据" not in script + assert 'class="v2-calibration-panel v2-calibration-mqtt-panel"' not in script + assert "grid-template-columns: minmax(420px, 1.06fr) minmax(380px, 0.94fr)" in styles assert "标定仿真台架" in script assert "X-Cognitive-Agent-Role" in script assert "--v2-sidebar: #dae3f5" in styles assert "--v2-sidebar-deep: #b6c7ea" in styles assert "--v2-accent-strong: #1e386b" in styles assert '["self-calibration", "self-repair"].includes(module)' in console_script - assert 'setRole("admin")' in console_script + assert 'setRole("admin", { allowModuleTransition: true })' in console_script assert len(model) == 845_992 assert content_type.startswith("model/gltf-binary") assert "img-src 'self' data: blob:;" in content_security_policy diff --git a/tests/e2e/test_self_repair_api.py b/tests/e2e/test_self_repair_api.py index fea045a..9bc020a 100644 --- a/tests/e2e/test_self_repair_api.py +++ b/tests/e2e/test_self_repair_api.py @@ -95,6 +95,17 @@ class TestSelfRepairApi: assert len(state["scenarios"]) == 6 assert state["hardware"]["connector_enabled_count"] == 21 + with urllib.request.urlopen( + self.request( + "/api/self-repair/runtime", + method="POST", + payload={"active": True, "execution_mode": "shadow"}, + ), + timeout=3, + ) as response: + runtime = json.loads(response.read().decode("utf-8"))["runtime"] + assert runtime["active"] is True + with urllib.request.urlopen( self.request( "/api/self-repair/healing/start", @@ -135,26 +146,37 @@ class TestSelfRepairApi: with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response: html = response.read().decode("utf-8") with urllib.request.urlopen( - f"{self.base_url}/self-repair.js?v=repair-17", timeout=3 + f"{self.base_url}/self-repair.js?v=repair-20", timeout=3 ) as response: - script = response.read().decode("utf-8") + script = response.read().decode("utf-8").replace("\r\n", "\n") with urllib.request.urlopen( - f"{self.base_url}/self-repair.css?v=repair-17", timeout=3 + f"{self.base_url}/self-repair.css?v=repair-21", timeout=3 ) as response: styles = response.read().decode("utf-8") with urllib.request.urlopen( - f"{self.base_url}/self-repair-seat3d.js?v=repair-17", timeout=3 + f"{self.base_url}/self-repair-seat3d.js?v=repair-18", timeout=3 ) as response: seat3d = response.read().decode("utf-8") with urllib.request.urlopen( - f"{self.base_url}/console-v2.js?v=desktop-refactor-28", timeout=3 + f"{self.base_url}/console-v2.js?v=desktop-refactor-31", timeout=3 ) as response: console_script = response.read().decode("utf-8") - assert 'href="/self-repair.css?v=repair-17"' in html - assert 'src="/self-repair.js?v=repair-17"' in html - assert 'src="/self-repair-seat3d.js?v=repair-17"' in html - assert "量产故障用例" in script + assert 'href="/self-repair.css?v=repair-21"' in html + assert 'src="/self-repair.js?v=repair-20"' in html + assert 'class="v2-module-heading"' in script + assert 'SELF-REPAIR' in script + assert 'padding: var(--v2-module-title-block) 0 0' in styles + assert 'margin-inline: var(--v2-module-title-inline)' in styles + assert 'src="/self-repair-seat3d.js?v=repair-18"' in html + assert "量产故障注入" in script + assert "运行与自修复日志" in script + assert "启动座椅运行" in script + assert "开始正常任务" not in script + assert 'id="repairSeatCanvas"' in script + assert 'id="repairSeatViewer"' in script + assert "self-repair-seat-reset" in script + assert "seat-healing-state" in script assert 'id="repairConfirmDialog"' in script assert "confirmAction(" in script assert "window.confirm" not in script @@ -163,10 +185,15 @@ class TestSelfRepairApi: assert "connectorRows" in script assert "Polling refreshes data only" in script assert "renderRecords();\n showView(activeView);" not in script + assert 'let latest = fallbackState();' in script + assert 'renderAll();\n window.lucide?.createIcons' in script + assert '"WDT复位后失能"' in script assert "--repair-deep-blue" in styles assert '.console-v2[data-module="self-repair"] .section-kicker' in styles - assert 'grid-template-areas: "twin cases timeline"' in styles - assert "background: color-mix(in srgb, var(--v2-accent-soft) 82%, white)" in styles + assert 'grid-template-areas: "twin cases logs"' in styles + assert ".v2-repair-event-stream" in styles + assert "background: rgba(255, 255, 255, 0.94)" in styles + assert "border-color: color-mix(in srgb, var(--v2-accent) 18%, white)" in styles assert ".v2-repair-toolbar button.primary:hover:not(:disabled)" in styles assert "color: #fff;" in styles assert "seat-healing-state" in seat3d @@ -175,7 +202,7 @@ class TestSelfRepairApi: assert 'cssThemeColor("--v2-accent", "#315eaa")' in seat3d assert 'viewer.addEventListener("pointerdown"' in seat3d assert 'window.addEventListener("pointermove"' in seat3d - assert 'window.addEventListener("self-repair-seat-reset"' in seat3d + assert "window.addEventListener(resetEvent" in seat3d assert '["self-calibration", "self-repair"].includes(module)' in console_script assert 'roleSelect.disabled = ui.activeModule === "self-repair"' in console_script diff --git a/tests/unit/test_frontend_vehicle_state_sync_contract.py b/tests/unit/test_frontend_vehicle_state_sync_contract.py index 5f8d124..62a108b 100644 --- a/tests/unit/test_frontend_vehicle_state_sync_contract.py +++ b/tests/unit/test_frontend_vehicle_state_sync_contract.py @@ -47,6 +47,49 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase): self.extension_source, ) + def test_shared_twin_theme_tracks_demo_role_and_extension_is_always_user(self) -> None: + self.assertIn("function applyTwinSceneTheme(", self.app_source) + self.assertIn('canvas.dataset.themeRole = themeRole', self.app_source) + self.assertIn( + 'canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`', + self.app_source, + ) + self.assertIn("accent: 0x0e7773", self.app_source) + self.assertIn("accent: 0x315eaa", self.app_source) + self.assertIn( + 'if (document.body.dataset.module === "self-extension") return "user"', + self.app_source, + ) + self.assertIn( + 'const themeRole = document.body.dataset.module === "self-extension"', + self.app_source, + ) + self.assertIn( + 'window.addEventListener("cognitive-agent:demo-role-change"', + self.app_source, + ) + + def test_shared_twin_ground_uses_theme_radial_alpha_texture(self) -> None: + self.assertIn("function createTwinGroundTexture(", self.app_source) + self.assertIn("new THREE.PlaneGeometry(30, 30)", self.app_source) + self.assertIn( + 'surfaceGradient.addColorStop(0, "rgba(255, 255, 255, 1)")', + self.app_source, + ) + self.assertIn( + 'surfaceGradient.addColorStop(1, "rgba(255, 255, 255, 0)")', + self.app_source, + ) + self.assertIn("const outerRadius = size * 0.48", self.app_source) + self.assertIn( + "const gridColor = accent.clone().lerp(new THREE.Color(0xffffff), 0.52)", + self.app_source, + ) + self.assertIn("gridContext.lineWidth = major ? 0.9 : 0.45", self.app_source) + self.assertIn('gridContext.globalCompositeOperation = "destination-in"', self.app_source) + self.assertIn("applyTwinGroundTheme(twin, palette)", self.app_source) + self.assertNotIn("new THREE.GridHelper(10, 20", self.app_source) + def test_lan_http_origin_does_not_require_crypto_random_uuid(self) -> None: id_source = function_source( self.app_source, diff --git a/tests/unit/test_self_repair.py b/tests/unit/test_self_repair.py index b52694f..6271b0b 100644 --- a/tests/unit/test_self_repair.py +++ b/tests/unit/test_self_repair.py @@ -4,6 +4,8 @@ import json import time from pathlib import Path +import pytest + from cognitive_agent.application.self_repair.healing_model import ( PHASE_DIAGNOSED, PHASE_INJECTED, @@ -75,6 +77,30 @@ def test_service_is_safe_without_machine_specific_tool_paths() -> None: assert service.run_self_test() is True +def test_runtime_must_start_before_fault_injection() -> None: + service = SelfRepairService() + + with pytest.raises(ValueError, match="请先启动座椅连续运行"): + service.start_healing({ + "case_id": "FI-FIELD-01", + "execution_mode": "shadow", + }) + + runtime = service.set_runtime({"active": True, "execution_mode": "shadow"}) + assert runtime["active"] is True + assert runtime["motion_profile"] == "四电机协同自由运动" + assert runtime["events"][0]["title"] == "座椅自由运动任务已启动" + + healing = service.start_healing({ + "case_id": "FI-FIELD-01", + "execution_mode": "shadow", + }) + assert healing["case_id"] == "FI-FIELD-01" + + snapshot = service.snapshot() + assert snapshot["runtime"]["injected_count"] == 1 + + def test_bridge_manager_does_not_arm_visible_tooling_by_default(tmp_path: Path) -> None: manager = ZcanBridgeManager( tmp_path / "bridge.json",