feat: refine vehicle console and self-repair workflow
This commit is contained in:
+711
-18
@@ -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 = `
|
||||
<span class="cabin-live-status" id="cabinLiveStatus"><i></i><b>通信正常 · 7 个设备在线</b></span>
|
||||
<button class="cabin-expand-button" id="cabinExpandButton" type="button" aria-expanded="false">
|
||||
<i data-lucide="maximize-2"></i><span>展开查看</span>
|
||||
</button>`;
|
||||
cabinHeading?.append(cabinHeadingActions);
|
||||
|
||||
const cabinToolbar = document.createElement("div");
|
||||
cabinToolbar.className = "cabin-board-toolbar";
|
||||
cabinToolbar.innerHTML = `
|
||||
<dl class="cabin-vehicle-summary" aria-label="车辆实时概况">
|
||||
<div><dt>当前档位</dt><dd id="cabinSummaryGear">--</dd></div>
|
||||
<div><dt>实时车速</dt><dd id="cabinSummarySpeed">-- km/h</dd></div>
|
||||
<div><dt>门锁状态</dt><dd id="cabinSummaryDoors">状态未知</dd></div>
|
||||
<div><dt>系统状态</dt><dd id="cabinSummarySystem">无故障</dd></div>
|
||||
</dl>
|
||||
<div class="cabin-carousel-controls" aria-label="设备卡片浏览">
|
||||
<span class="cabin-carousel-count" aria-live="polite"><b id="cabinCarouselRange">1–3</b><i> / 7</i></span>
|
||||
<div role="group" aria-label="左右浏览设备卡片">
|
||||
<button id="cabinCarouselPrev" type="button" aria-label="查看上一组设备" disabled>
|
||||
<i data-lucide="chevron-left"></i>
|
||||
</button>
|
||||
<button id="cabinCarouselNext" type="button" aria-label="查看下一组设备">
|
||||
<i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = `
|
||||
<header class="device-power-drawer-heading">
|
||||
<div>
|
||||
<span class="section-kicker">DEVICE POWER INSIGHT</span>
|
||||
<h2 id="devicePowerTitle">设备功率详情</h2>
|
||||
<p><i></i><span id="devicePowerFreshness">实时更新 · 等待功率数据</span></p>
|
||||
</div>
|
||||
<button id="devicePowerClose" type="button" aria-label="关闭功率详情"><i data-lucide="x"></i></button>
|
||||
</header>
|
||||
<div class="device-power-drawer-body">
|
||||
<section class="device-power-overview" aria-label="设备功率摘要">
|
||||
<div class="device-power-total">
|
||||
<small>当前总功率</small>
|
||||
<strong><b id="devicePowerCurrent">--</b><span id="devicePowerCurrentUnit">W</span></strong>
|
||||
<em id="devicePowerDataState">等待测量数据</em>
|
||||
</div>
|
||||
<dl class="device-power-energy-summary">
|
||||
<div><dt>本次行程</dt><dd id="devicePowerTripEnergy">-- kWh</dd></div>
|
||||
<div><dt>今日累计</dt><dd id="devicePowerTodayEnergy">-- kWh</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="device-power-components" aria-labelledby="devicePowerComponentsTitle">
|
||||
<header>
|
||||
<div><span class="section-kicker">POWER BREAKDOWN</span><h3 id="devicePowerComponentsTitle">子部件当前功率</h3></div>
|
||||
<span id="devicePowerComponentTotal">合计 -- W</span>
|
||||
</header>
|
||||
<div class="device-power-component-list" id="devicePowerComponentList"></div>
|
||||
</section>
|
||||
<section class="device-power-trend" aria-labelledby="devicePowerTrendTitle">
|
||||
<header>
|
||||
<div><span class="section-kicker">POWER TREND</span><h3 id="devicePowerTrendTitle">功率趋势</h3></div>
|
||||
<div class="device-power-trend-tabs" role="group" aria-label="功率趋势时间范围">
|
||||
<button type="button" class="active" data-power-trend-range="trip">本次行程</button>
|
||||
<button type="button" data-power-trend-range="today">今日</button>
|
||||
<button type="button" data-power-trend-range="week">近7天</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="device-power-chart" id="devicePowerChart">
|
||||
<svg viewBox="0 0 520 180" preserveAspectRatio="none" aria-hidden="true">
|
||||
<g class="device-power-chart-grid">
|
||||
<line x1="36" y1="24" x2="504" y2="24"></line>
|
||||
<line x1="36" y1="74" x2="504" y2="74"></line>
|
||||
<line x1="36" y1="124" x2="504" y2="124"></line>
|
||||
<line x1="36" y1="160" x2="504" y2="160"></line>
|
||||
</g>
|
||||
<path class="device-power-chart-area" id="devicePowerChartArea" d=""></path>
|
||||
<polyline class="device-power-chart-line" id="devicePowerChartLine" points=""></polyline>
|
||||
</svg>
|
||||
<span class="device-power-chart-y top" id="devicePowerChartMax">-- W</span>
|
||||
<span class="device-power-chart-y bottom">0</span>
|
||||
<div class="device-power-chart-empty" id="devicePowerChartEmpty"><i data-lucide="chart-no-axes-column-increasing"></i><span>暂无功率趋势数据</span></div>
|
||||
<div class="device-power-chart-axis"><span id="devicePowerChartStart">行程开始</span><span id="devicePowerChartEnd">当前</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="device-power-note"><i data-lucide="info"></i><span id="devicePowerNote">总功率为设备级实时测量值。</span></footer>
|
||||
</div>`;
|
||||
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 = `
|
||||
<span><i data-lucide="gauge"></i></span>
|
||||
<div><strong>仅支持设备总功率</strong><p>${activePowerDevice.name}暂无可读的子部件功率通道,顶部总功率仍可独立查看。</p></div>`;
|
||||
componentList.append(empty);
|
||||
$("#devicePowerComponentTotal", powerDrawer).textContent = "设备级测量";
|
||||
} else {
|
||||
componentValues.forEach(({ label, value }) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "device-power-component-row";
|
||||
row.innerHTML = `
|
||||
<span>${label}</span>
|
||||
<i><b style="--power-share: ${value === null ? 0 : (value / maxComponentPower) * 100}%"></b></i>
|
||||
<strong>${formatPower(value)}</strong>`;
|
||||
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 = `
|
||||
<span class="device-power-copy"><i data-lucide="zap"></i><span><small>实时功率</small><b data-device-power-value>-- W</b></span></span>
|
||||
<span class="device-power-action">详情<i data-lucide="chevron-right"></i></span>`;
|
||||
entry.addEventListener("click", () => openPowerDrawer(device, entry));
|
||||
} else {
|
||||
entry.setAttribute("aria-label", `${name}暂不支持功率测量`);
|
||||
entry.innerHTML = `
|
||||
<span class="device-power-copy"><i data-lucide="zap-off"></i><span><small>功率测量</small><b>暂不支持</b></span></span>
|
||||
<span class="device-power-unavailable">无测量通道</span>`;
|
||||
}
|
||||
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 = `<i data-lucide="${expanded ? "minimize-2" : "maximize-2"}"></i>`;
|
||||
$("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;
|
||||
|
||||
Reference in New Issue
Block a user