feat: refine vehicle console and self-repair workflow

This commit is contained in:
vee
2026-08-13 17:38:04 +08:00
parent fd3c219c39
commit 0ec43052f3
20 changed files with 3925 additions and 552 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ Web 控制台提供一句话、快捷功能模式与手势控制入口,不在
当前页面包括:
- 顶部“自编排 / 自标定 / 自扩展 / 自修复”模块切换;user 进入自标定或自修复时自动切换为 admin,自修复停留期间不能降级为 user;自扩展包含外设学习与统一控制两种工作模式。
- 顶部“自编排 / 自标定 / 自扩展 / 自修复 / 自进化”模块切换;user 进入自标定或自修复时自动切换为 admin,自修复停留期间不能降级为 user;自扩展包含外设学习与统一控制两种工作模式,自进化同时面向 user 与 admin,以版本时间轴、改进证据和下一步计划呈现能力演进过程。
- 六类外设玩具的添加、红外能力学习、测试发射、单动作执行与本地能力目录。
- 基于已学习外设能力和车端能力的 GPT 联动计划,车端步骤复用现有安全编排链路。
- 一句话联动座椅、空调、天幕和氛围灯,支持 Enter 执行和 GPT/本地规则切换。
+711 -18
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+230 -10
View File
@@ -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", `
<span class="section-kicker">手势控制</span>
const gestureHeading = make("header", "v2-operation-heading v2-module-heading v2-gesture-operation-heading", `
<span class="section-kicker">SELF-ORCHESTRATION</span>
<h2>抬抬手,控制整车</h2>
<p class="panel-intro">通过摄像头识别手势,直观控制车舱功能</p>`);
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) => `
<button type="button" class="v2-evolution-version${index === 0 ? " active" : ""}"
data-evolution-index="${index}" aria-pressed="${index === 0 ? "true" : "false"}">
<span><strong>${entry.version}</strong><time datetime="${entry.date.replaceAll(".", "-")}">${entry.date.slice(5)}</time></span>
<b>${entry.title}</b>
<small>${entry.status}</small>
</button>`).join("");
const evolution = make("section", "v2-self-evolution", `
<div class="v2-evolution-shell">
<header class="v2-evolution-overview">
<div class="v2-evolution-heading v2-module-heading">
<span class="section-kicker">SELF-EVOLUTION</span>
<h1>能力进化日志</h1>
<p>记录每次问题发现、能力升级与验证结果,见证系统持续进化。</p>
</div>
<dl class="v2-evolution-summary" aria-label="当前进化概览">
<div class="v2-evolution-current"><dt>当前版本</dt><dd>v0.5</dd><small>能力融合期</small></div>
<div><dt>累计迭代</dt><dd>5</dd><small>个版本</small></div>
<div><dt>能力改进</dt><dd>12</dd><small>项记录</small></div>
<div><dt>最近更新</dt><dd>08.12</dd><small>2026</small></div>
</dl>
</header>
<div class="v2-evolution-workspace">
<aside class="v2-evolution-timeline" aria-label="版本进化时间轴">
<div class="v2-evolution-timeline-heading">
<span>EVOLUTION PATH</span>
<strong>从起点到现在</strong>
</div>
<div class="v2-evolution-version-list">${versionButtons}</div>
</aside>
<article class="v2-evolution-detail" id="v2EvolutionDetail" aria-live="polite">
<header class="v2-evolution-detail-heading">
<div>
<span class="v2-evolution-status" id="v2EvolutionStatus"></span>
<span class="v2-evolution-detail-version" id="v2EvolutionVersion"></span>
</div>
<time id="v2EvolutionDate"></time>
</header>
<h2 id="v2EvolutionTitle"></h2>
<p class="v2-evolution-lead" id="v2EvolutionLead"></p>
<div class="v2-evolution-detail-grid">
<section class="v2-evolution-changes" aria-labelledby="v2EvolutionChangesTitle">
<h3 id="v2EvolutionChangesTitle"><i data-lucide="git-commit-horizontal"></i> 本次进化</h3>
<div id="v2EvolutionChangeList"></div>
</section>
<section class="v2-evolution-evidence" aria-labelledby="v2EvolutionEvidenceTitle">
<h3 id="v2EvolutionEvidenceTitle"><i data-lucide="trending-up"></i> 变好的证据</h3>
<div id="v2EvolutionMetrics"></div>
</section>
</div>
<footer class="v2-evolution-validation">
<div class="v2-evolution-module-tags" id="v2EvolutionModules" aria-label="关联模块"></div>
<p><i data-lucide="badge-check"></i><span data-evolution-scope="user" id="v2EvolutionUserValidation"></span><span data-evolution-scope="admin" id="v2EvolutionAdminValidation"></span></p>
</footer>
</article>
</div>
<footer class="v2-evolution-next">
<span class="v2-evolution-next-icon"><i data-lucide="flag"></i></span>
<div><small>NEXT EVOLUTION · v0.6</small><strong>自主评估闭环</strong><p>让系统主动发现能力退化,对比历史基线,并形成下一轮优化建议。</p></div>
<div class="v2-evolution-next-steps" aria-label="下一版本计划"><span>自动复盘</span><i data-lucide="arrow-right"></i><span>对比基线</span><i data-lucide="arrow-right"></i><span>形成建议</span></div>
</footer>
</div>`);
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]) => `
<div><i data-lucide="check"></i><p><strong>${label}</strong><span>${copy}</span></p></div>`).join("");
$("#v2EvolutionMetrics", evolution).innerHTML = entry.metrics.map(([label, before, after, progress]) => `
<div class="v2-evolution-metric">
<div><span>${label}</span><p><del>${before}</del><i data-lucide="arrow-right"></i><strong>${after}</strong></p></div>
<span class="v2-evolution-progress" style="--evolution-progress: ${progress}%"><i></i></span>
</div>`).join("");
$("#v2EvolutionModules", evolution).innerHTML = entry.modules
.map((module) => `<span>${module}</span>`).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", `
<header class="v2-output-heading">
@@ -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");
+12 -11
View File
@@ -10,23 +10,23 @@
<link rel="icon" href="/icons/app-icon-192.png" />
<title>智舱随行 · 整车智能控制台</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-9" />
<link rel="stylesheet" href="/console-v2.css?v=desktop-refactor-89" />
<link rel="stylesheet" href="/self-extension.css?v=extension-12" />
<link rel="stylesheet" href="/self-calibration.css?v=calibration-6" />
<link rel="stylesheet" href="/self-repair.css?v=repair-17" />
<link rel="stylesheet" href="/console-v2.css?v=desktop-refactor-106" />
<link rel="stylesheet" href="/self-extension.css?v=extension-18" />
<link rel="stylesheet" href="/self-calibration.css?v=calibration-10" />
<link rel="stylesheet" href="/self-repair.css?v=repair-21" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-1" defer></script>
<script src="/app.js?v=dual-seat-67" defer></script>
<script src="/app.js?v=dual-seat-80" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-7"></script>
<script src="/pwa.js?v=android-pad-1" defer></script>
<script src="/console-v2.js?v=desktop-refactor-30" defer></script>
<script src="/self-extension.js?v=extension-18" defer></script>
<script src="/self-calibration.js?v=calibration-3" defer></script>
<script src="/self-repair.js?v=repair-17" defer></script>
<script src="/self-repair-seat3d.js?v=repair-17" defer></script>
<script src="/console-v2.js?v=desktop-refactor-34" defer></script>
<script src="/self-extension.js?v=extension-20" defer></script>
<script src="/self-calibration.js?v=calibration-6" defer></script>
<script src="/self-repair.js?v=repair-20" defer></script>
<script src="/self-repair-seat3d.js?v=repair-18" defer></script>
</head>
<body>
<header class="topbar">
@@ -393,7 +393,8 @@
<section class="twin-band" aria-labelledby="twinTitle">
<div class="twin-heading">
<div>
<div class="twin-title-copy">
<span class="section-kicker">LIVE VEHICLE DIGITAL TWIN</span>
<h3 id="twinTitle">整车孪生界面</h3>
<div class="preset-indicator" id="presetIndicator" hidden>
<i data-lucide="bookmark-check"></i><span></span>
+75 -43
View File
@@ -29,24 +29,6 @@
linear-gradient(158deg, var(--v2-sidebar) 0%, #eef2fa 61%, var(--v2-sidebar-deep) 132%);
}
.console-v2[data-module="self-calibration"] .v2-avatar {
width: 46px;
height: 46px;
background:
radial-gradient(circle at 28% 25%, rgba(255, 255, 255, 0.96), transparent 18%),
linear-gradient(135deg, #183c7a 3%, #6688d4 48%, #e5b0c8 55%, #315eaa 100%);
}
.console-v2[data-module="self-calibration"] .v2-profile {
grid-template-columns: 46px minmax(0, 1fr) 28px;
gap: 8px;
}
.console-v2[data-module="self-calibration"] .v2-role-select > span,
.console-v2[data-module="self-calibration"] .v2-role-select .v2-dropdown-trigger {
font-size: 18px;
}
.console-v2[data-module="self-calibration"] .v2-role-select > .v2-dropdown-trigger {
max-width: none;
gap: 5px;
@@ -80,7 +62,7 @@
grid-row: 2;
grid-template-rows: auto minmax(0, 1fr);
gap: 15px;
padding: clamp(14px, 1.55vw, 24px) 0 0;
padding: var(--v2-module-title-block) 0 0;
overflow: hidden;
}
@@ -98,7 +80,7 @@
}
.v2-calibration-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;
@@ -123,14 +105,15 @@
.v2-calibration-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-calibration-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(30, 56, 107, 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-calibration-mode-switch button.active svg {
@@ -139,28 +122,30 @@
.v2-calibration-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-calibration-config-card {
min-width: 0;
min-height: 52px;
min-height: var(--v2-sidebar-card-height);
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 9px 11px;
gap: 8px;
padding: 6px 8px;
color: #314565;
background: rgba(255, 255, 255, 0.52);
border: 1px solid rgba(49, 94, 170, 0.11);
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-calibration-config-card > svg {
width: 18px;
height: 18px;
width: 17px;
height: 17px;
justify-self: center;
color: #5f7fb7;
color: var(--v2-accent);
}
.v2-calibration-config-card > div {
@@ -170,14 +155,15 @@
.v2-calibration-config-card small {
color: #7786a2;
font-size: 9px;
font-size: 8px;
font-weight: 700;
}
.v2-calibration-config-card strong {
overflow: hidden;
color: #1e386b;
font-size: 12px;
margin-top: 2px;
font-size: 11px;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
@@ -207,7 +193,7 @@
width: 100%;
height: 18px;
color: #1e386b;
font-size: 12px;
font-size: 11px;
font-weight: 850;
text-align: left;
}
@@ -274,6 +260,7 @@
align-items: center;
justify-content: space-between;
gap: 18px;
margin-inline: var(--v2-module-title-inline);
}
.v2-calibration-heading > div:first-child {
@@ -466,17 +453,32 @@
overflow: hidden;
}
.v2-calibration-seat-scene .calibration-cartoon-viewer {
cursor: grab;
touch-action: none;
user-select: none;
}
.v2-calibration-seat-scene .calibration-cartoon-viewer.dragging {
cursor: grabbing;
}
.v2-calibration-seat-scene .seat-model-host canvas {
width: 100%;
height: 100%;
display: block;
outline: 0;
cursor: grab;
pointer-events: none;
touch-action: none;
}
.v2-calibration-seat-scene .seat-model-host canvas:active {
cursor: grabbing;
.v2-calibration-seat-scene .v2-repair-seat-fallback {
inset: 4px 18px 18px;
transform: scale(0.82);
}
.v2-calibration-seat-scene .repair-css-halo {
bottom: 10px;
}
.v2-calibration-seat-scene .model-loading {
@@ -1090,7 +1092,7 @@
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: minmax(300px, 1.15fr) minmax(250px, 0.94fr) minmax(270px, 0.91fr);
grid-template-columns: minmax(420px, 1.06fr) minmax(380px, 0.94fr);
gap: 10px;
}
@@ -1108,8 +1110,7 @@
}
.v2-calibration-live-grid .v2-calibration-stage > header,
.v2-calibration-live-grid .v2-calibration-video-panel > header,
.v2-calibration-live-grid .v2-calibration-mqtt-panel > header {
.v2-calibration-live-grid .v2-calibration-video-panel > header {
align-items: center;
}
@@ -1460,6 +1461,37 @@
margin-top: auto;
}
@media (max-height: 820px) {
.console-v2[data-module="self-calibration"] .v2-calibration-sidebar {
margin-top: 14px;
}
.v2-calibration-sidebar .v2-section-heading h2 {
margin-bottom: 10px;
}
.v2-calibration-sidebar > .v2-field-caption {
margin-bottom: 5px;
font-size: 11px;
}
.v2-calibration-mode-switch button {
min-height: 41px;
grid-template-columns: 21px minmax(0, 1fr);
padding: 0 9px;
font-size: 10px;
}
.v2-calibration-mode-switch button svg {
width: 17px;
height: 17px;
}
.v2-calibration-config-card {
min-height: 38px;
}
}
@media (max-width: 1180px) {
.v2-calibration-hero-grid {
grid-template-columns: minmax(0, 1.5fr) minmax(245px, 0.75fr);
@@ -1474,7 +1506,7 @@
}
.v2-calibration-live-grid {
grid-template-columns: minmax(220px, 1.1fr) minmax(210px, 0.94fr) minmax(225px, 0.96fr);
grid-template-columns: minmax(390px, 1.06fr) minmax(340px, 0.94fr);
}
.v2-calibration-live-axes .v2-calibration-axis-grid {
+23 -115
View File
@@ -4,8 +4,8 @@
const API_ROOT = "/api/calibration";
const TARGET_KEY = "cognitive-agent.calibration-target";
const pageTitles = {
live: ["SEAT · ZED JOINT CALIBRATION", "座椅—双目联合标定", "座椅姿态、四轴 Hall、ZED 画面与双目位姿同步更新"],
system: ["CONNECTION SETTINGS", "连接与运行设置", "CAN、MQTT 与 RTSP 接入参数"],
live: ["SELF-CALIBRATION", "座椅与双目联合标定", "同步标定座椅姿态、四轴 Hall 与 ZED 实时画面"],
system: ["SELF-CALIBRATION · SETTINGS", "连接与运行设置", "配置 CAN、MQTT 与 RTSP 接入参数"],
};
const phaseLabels = {
0: "待命",
@@ -25,7 +25,6 @@
let reconnectTimer = 0;
let toastTimer = 0;
let settingsHydrated = false;
let modelRequested = false;
let activeView = "live";
function buildShell() {
@@ -83,10 +82,10 @@
root.setAttribute("aria-label", "N7 自标定工作台");
root.innerHTML = `
<header class="v2-calibration-heading">
<div>
<span class="section-kicker" id="calibrationPageKicker">SEAT · ZED JOINT CALIBRATION</span>
<h1 id="calibrationPageTitle">座椅—双目联合标定</h1>
<p id="calibrationPageDescription">座椅姿态、四轴 Hall、ZED 画面与双目位姿同步更新</p>
<div class="v2-module-heading">
<span class="section-kicker" id="calibrationPageKicker">SELF-CALIBRATION</span>
<h1 id="calibrationPageTitle">座椅与双目联合标定</h1>
<p id="calibrationPageDescription">同步标定座椅姿态、四轴 Hall 与 ZED 实时画面</p>
</div>
<div class="v2-calibration-statuses">
<div><i id="calibrationCanDot"></i><span><small>CAN</small><strong id="calibrationCanState">未连接</strong></span></div>
@@ -101,12 +100,12 @@
<header><div><span class="section-kicker">DIGITAL TWIN</span><h2>实时座椅姿态</h2></div><span class="v2-calibration-badge wait" id="calibrationPhaseBadge">待命</span></header>
<div class="v2-calibration-seat-scene">
<div class="v2-calibration-floor-grid"></div>
<div id="seatModelHost" class="seat-model-host">
<canvas id="seatCanvas" aria-label="N7 原始三维座椅模型,可拖动旋转并滚轮缩放"></canvas>
<div id="calibrationSeatViewer" class="seat-model-host calibration-cartoon-viewer">
<canvas id="calibrationSeatCanvas" aria-label="自标定卡通风格三维座椅,可拖动旋转并滚轮缩放"></canvas>
<div id="seatModelLoading" class="model-loading">
<span class="model-spinner"></span>
<strong>正在加载原始 N7 座椅</strong>
<small>ANDROID GLB · LOCAL</small>
<strong>正在构建座椅数字孪生</strong>
<small>CALIBRATION · CARTOON TWIN</small>
</div>
<div class="orbit-hint">拖动旋转 · 滚轮缩放 · 双击复位</div>
</div>
@@ -127,17 +126,6 @@
<div class="v2-calibration-actions"><button id="calibrationVideoStart" type="button">开始接收</button><button id="calibrationVideoStop" type="button">停止</button></div>
</section>
<section class="v2-calibration-panel v2-calibration-mqtt-panel">
<header><div><span class="section-kicker">ZED / SEATPOSE</span><h2>双目位姿数据</h2></div><span class="v2-calibration-badge wait" id="calibrationMqttBadge">等待数据</span></header>
<div class="v2-calibration-metrics"><div><small>消息总数</small><strong id="calibrationMqttCount">0</strong></div><div><small>最新帧</small><strong id="calibrationMqttFrame">—</strong></div><div><small>解析错误</small><strong id="calibrationMqttErrors">0</strong></div></div>
<p class="v2-calibration-detail" id="calibrationMqttDetail">MQTT 尚未连接</p>
<div class="v2-calibration-pose-areas" id="calibrationPoseAreas"></div>
<div class="v2-calibration-live-mapping">
<div class="v2-calibration-subheading"><span>HALL ↔ 3D</span><strong>实时映射</strong></div>
<div class="v2-calibration-mapping-rows" id="calibrationMappingRows"></div>
</div>
<div class="v2-calibration-actions"><button id="calibrationMqttConnect" type="button">连接 MQTT</button><button id="calibrationMqttDisconnect" type="button">断开</button><button id="calibrationMqttTest" type="button">回环测试</button></div>
</section>
</div>
<section class="v2-calibration-panel v2-calibration-axis-section v2-calibration-live-axes">
@@ -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></strong><span></span><div><i></i></div><b></b>";
$("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 接收",
+38 -25
View File
@@ -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 {
+3 -7
View File
@@ -465,7 +465,7 @@
root.innerHTML = `
<section class="v2-extension-view v2-extension-study" data-extension-view="study">
<header class="v2-extension-heading">
<div><span class="section-kicker">PERIPHERAL LEARNING</span><h1>外设玩具能力一览与学习</h1><p>为六类外设添加具体硬件,学习并验证遥控动作。</p></div>
<div class="v2-module-heading"><span class="section-kicker">SELF-EXTENSION</span><h1>外设玩具能力学习</h1><p>添加外设硬件,学习并验证可执行的遥控动作。</p></div>
<div class="v2-extension-heading-actions">
<button class="v2-extension-reset-action" type="button" id="extensionResetButton"><i data-lucide="rotate-ccw"></i>重置学习</button>
<button type="button" data-add-type="light"><i data-lucide="plus"></i>添加玩具</button>
@@ -475,7 +475,7 @@
</section>
<section class="v2-extension-view v2-extension-control" data-extension-view="control" hidden>
<header class="v2-extension-heading">
<div><span class="section-kicker">UNIFIED CONTROL</span><h1>说一句,联动外设玩具与车端设备</h1><p>GPT 只从已学习能力与受控车端能力中生成计划。</p></div>
<div class="v2-module-heading"><span class="section-kicker">SELF-EXTENSION</span><h1>外设玩具与车端联动</h1><p>用自然语言统一编排已学习的玩具动作与受控车端设备。</p></div>
</header>
<div class="v2-extension-command-row">
<div class="v2-extension-composer">
@@ -497,11 +497,7 @@
<div class="v2-extension-vehicle-grid" id="extensionVehicleGrid"></div>
</section>
</div>
<section class="v2-extension-twin-board" aria-labelledby="extensionTwinTitle">
<header>
<div><span class="section-kicker">SHARED VEHICLE DIGITAL TWIN</span><h2 id="extensionTwinTitle">整车孪生实时映射</h2><p>与自编排复用同一孪生实例,CH0 副驾座椅与 CH1 主驾座椅 / 车身状态持续同步。</p></div>
<span><i></i>实际反馈 / 当前动作</span>
</header>
<section class="v2-extension-twin-board" aria-label="整车孪生界面">
<div class="v2-extension-twin-host" id="extensionTwinHost">
<div class="v2-extension-twin-waiting"><i data-lucide="loader-circle"></i><span>正在挂载整车孪生界面</span></div>
</div>
+53 -15
View File
@@ -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 @@
</div>
<span class="repair-css-halo"></span>`;
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 {
+213 -126
View File
@@ -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;
}
+246 -88
View File
@@ -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(">", "&gt;")
.replaceAll('"', "&quot;");
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 = `
<header class="v2-repair-heading">
<div><span class="section-kicker">COGNITIVE SEAT / SELF-REPAIR</span><h1>座椅AI自修复验证台</h1><p>量产软件故障闭环恢复 · 开发板接口在线诊断</p></div>
<div class="v2-module-heading"><span class="section-kicker">SELF-REPAIR</span><h1>座椅 AI 自修复验证</h1><p>座椅持续自由运行 · 故障独立注入 · 全过程事件留痕</p></div>
<div class="v2-repair-status-grid">
<div><i id="repairCanDot"></i><span><small>CAN桥接</small><strong id="repairCanStatus">离线</strong></span></div>
<div><i id="repairProtocolDot"></i><span><small>座椅ECU</small><strong id="repairProtocolStatus">未检测</strong></span></div>
@@ -128,22 +173,15 @@
<section class="v2-repair-view" data-repair-page="software">
<header class="v2-repair-toolbar">
<div><span>01</span><div><h2>软件自修复</h2><p>正常任务 → 故障停止 → AI 判断 → 安全修复 → 继续原目标</p></div></div>
<div><span>01</span><div><h2>连续运行与自修复验证</h2><p>运行任务与故障注入相互独立;选择用例不会启动或重置座椅</p></div></div>
<div>
<button type="button" id="repairSoftwareRecords"><i data-lucide="history"></i>验证记录</button>
<button type="button" id="repairFirmwareFlash"><i data-lucide="microchip"></i>构建/烧录</button>
<button type="button" id="repairReset"><i data-lucide="rotate-ccw"></i>重置归零</button>
<button type="button" id="repairRunAll"><i data-lucide="list-checks"></i>批量验证6项</button>
<button class="primary" type="button" id="repairStart"><i data-lucide="play"></i>启动当前用例</button>
<button type="button" id="repairReset"><i data-lucide="rotate-ccw"></i>重置统计</button>
<button class="primary" type="button" id="repairRuntimeToggle"><i data-lucide="play"></i>启动座椅运行</button>
</div>
</header>
<div class="v2-repair-software-grid">
<section class="v2-repair-panel v2-repair-cases-panel">
<header><div><span class="section-kicker">PRODUCTION CASES</span><h2>量产故障用例</h2></div><b id="repairBatchCount">0 / 6</b></header>
<div class="v2-repair-overall-progress" aria-label="六项用例总体进度"><span id="repairBatchProgress"></span></div>
<p id="repairBatchSummary">等待启动批量验证</p>
<div class="v2-repair-case-list" id="repairCaseList"></div>
</section>
<section class="v2-repair-panel v2-repair-twin-panel">
<header><div><span class="section-kicker">DIGITAL TWIN</span><h2>座椅3D孪生</h2></div><b id="repairTwinState">正常基线</b></header>
<div class="v2-repair-seat-viewer" id="repairSeatViewer">
@@ -153,9 +191,31 @@
<div class="v2-repair-twin-live"><div><small id="repairTwinSource">影子动作仿真</small><strong id="repairTwinTask">等待启动座椅任务</strong><b id="repairTwinStage">待机</b></div><div id="repairAxisStrip"></div><p id="repairTwinReaction">实际动作将同步显示在座椅模型</p></div>
</div>
</section>
<section class="v2-repair-panel v2-repair-timeline-panel">
<header><div><span class="section-kicker">HEALING FLOW</span><h2>座椅任务自修复</h2></div><b id="repairElapsed">0.000 s</b></header>
<div class="v2-repair-timeline" id="repairTimeline"></div>
<section class="v2-repair-panel v2-repair-cases-panel">
<header><div><span class="section-kicker">FAULT INJECTION</span><h2>量产故障注入</h2></div><b id="repairCaseCount">6 个用例</b></header>
<p class="v2-repair-injection-note">先启动座椅持续运行,再按需选择故障。注入事件会明确标记为管理员操作。</p>
<div class="v2-repair-case-list" id="repairCaseList"></div>
<footer class="v2-repair-injection-footer">
<small id="repairSelectedCase">当前选择:—</small>
<button class="primary" type="button" id="repairInject"><i data-lucide="syringe"></i>注入所选故障</button>
<span id="repairInjectionCount">本次尚未注入故障</span>
</footer>
</section>
<section class="v2-repair-panel v2-repair-log-panel">
<header><div><span class="section-kicker">EVENT STREAM</span><h2>运行与自修复日志</h2></div><b id="repairLogState" data-state="idle">等待启动</b></header>
<div class="v2-repair-log-summary">
<div><small>本次连续运行</small><strong id="repairRuntimeElapsed">00:00:00</strong></div>
<div><small>累计健康运行</small><strong id="repairHealthyElapsed">00:00:00</strong></div>
<div><small>故障 / 已修复</small><strong id="repairFaultSummary">0 / 0</strong></div>
</div>
<div class="v2-repair-log-toolbar">
<div role="group" aria-label="日志筛选">
<button class="active" type="button" data-repair-log-filter="all">全部事件</button>
<button type="button" data-repair-log-filter="fault">仅故障与修复</button>
</div>
<span><i></i>自动跟随最新日志</span>
</div>
<div class="v2-repair-event-stream" id="repairEventStream" aria-live="polite"></div>
</section>
</div>
</section>
@@ -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) => `<article data-kind="${esc(event.kind)}"><time>${esc(event.time || "--:--:--.---")}</time><i></i><div><header><span>${esc(event.label)}</span><strong>${esc(event.title)}</strong></header><p>${esc(event.detail || "状态已更新")}</p>${event.tags?.length ? `<footer>${event.tags.map((tag) => `<span>${esc(tag)}</span>`).join("")}</footer>` : ""}</div></article>`).join("")
: `<div class="v2-repair-log-empty"><i data-lucide="scroll-text"></i><strong>${runtime.active ? "座椅正在正常运行" : "等待启动座椅运行"}</strong><span>${runtime.active ? "运行心跳、故障、诊断和修复事件将在这里持续追加。" : "启动后将记录运行时长、四轴状态和后续故障注入。"}</span></div>`;
$("#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 `<article data-state="${state}"><i></i><div><header><strong>${esc(stage.title)}</strong><span>${label}</span></header><p>${esc(timelineDetail(index, scenario, healing))}</p></div></article>`;
}).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 = `<i data-lucide="syringe"></i>${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 `<span class="${axis.active ? "active" : ""}"><b>M${axis.motor}</b><i style="--position:${position}%"></i><em>${position.toFixed(0)}%</em></span>`;
@@ -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
? '<i data-lucide="square"></i>停止座椅运行'
: '<i data-lucide="play"></i>启动座椅运行';
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);
+11 -12
View File
@@ -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",
@@ -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"))
+10
View File
@@ -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
+118 -16
View File
@@ -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('<span>Hi,</span><strong>user</strong>', 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('<span class="section-kicker">SELF-EVOLUTION</span>', 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('<small>手势模型</small>', 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">整车设备状态</h3>', 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('<span class="section-kicker">LIVE VEHICLE DIGITAL TWIN</span>', 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")
+14 -8
View File
@@ -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
+39 -12
View File
@@ -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
@@ -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,
+26
View File
@@ -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",