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 联动计划,车端步骤复用现有安全编排链路。 - 基于已学习外设能力和车端能力的 GPT 联动计划,车端步骤复用现有安全编排链路。
- 一句话联动座椅、空调、天幕和氛围灯,支持 Enter 执行和 GPT/本地规则切换。 - 一句话联动座椅、空调、天幕和氛围灯,支持 Enter 执行和 GPT/本地规则切换。
+711 -18
View File
@@ -137,18 +137,475 @@ function initJointTwinLayout() {
if (seatFeedback) coverage.prepend(seatFeedback); if (seatFeedback) coverage.prepend(seatFeedback);
domainGrid.prepend(driverCard); domainGrid.prepend(driverCard);
domainGrid.prepend(passengerCard); domainGrid.prepend(passengerCard);
const updateDomainGridScrollState = () => {
const isScrollable = domainGrid.scrollHeight > domainGrid.clientHeight + 2; const vehicleStatusPanel = $('[data-domain-panel="vehicle_status"]', domainGrid);
const isAtBottom = domainGrid.scrollTop + domainGrid.clientHeight >= domainGrid.scrollHeight - 2; if (vehicleStatusPanel) {
domainGrid.dataset.scrollable = String(isScrollable); vehicleStatusPanel.classList.add("cabin-telemetry-source");
domainGrid.dataset.atBottom = String(!isScrollable || isAtBottom); 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) { if ("ResizeObserver" in window) {
const domainGridObserver = new ResizeObserver(updateDomainGridScrollState); const domainGridObserver = new ResizeObserver(() => {
updateCabinCarouselState();
scrollToCabinCard(cabinCarouselIndex, false);
});
domainGridObserver.observe(domainGrid); domainGridObserver.observe(domainGrid);
} }
requestAnimationFrame(updateDomainGridScrollState); requestAnimationFrame(() => {
updateCabinCarouselState();
scrollToCabinCard(0, false);
});
coverage.classList.add("joint-hardware-boundary"); coverage.classList.add("joint-hardware-boundary");
shell.append(coverage); shell.append(coverage);
stateBand.remove(); stateBand.remove();
@@ -1064,6 +1521,23 @@ function createTwinCabin(THREE, scene) {
const TWIN_THEME_PALETTES = { const TWIN_THEME_PALETTES = {
user: { 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, glass: 0x258f89,
glassEmissive: 0x124c49, glassEmissive: 0x124c49,
glassEdge: 0x086861, glassEdge: 0x086861,
@@ -1075,6 +1549,23 @@ const TWIN_THEME_PALETTES = {
lampEdge: 0x096d67, lampEdge: 0x096d67,
}, },
admin: { 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, glass: 0x477bc4,
glassEmissive: 0x1f427c, glassEmissive: 0x1f427c,
glassEdge: 0x234f91, glassEdge: 0x234f91,
@@ -1088,12 +1579,107 @@ const TWIN_THEME_PALETTES = {
}; };
function currentTwinThemeRole() { function currentTwinThemeRole() {
if (document.body.dataset.module === "self-extension") return "user";
return document.body.dataset.demoRole === "admin" return document.body.dataset.demoRole === "admin"
|| sessionStorage.getItem("cognitive-agent.demo-role") === "admin" || sessionStorage.getItem("cognitive-agent.demo-role") === "admin"
? "admin" ? "admin"
: "user"; : "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()) { function applyTwinVehicleTheme(systems, role = currentTwinThemeRole()) {
if (!systems) return; if (!systems) return;
const themeRole = role === "admin" ? "admin" : "user"; 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) { function updateTwinVehicleVisual(systems, timestamp) {
if (!systems) return; if (!systems) return;
const ease = (current, target, factor = 0.08) => ( const ease = (current, target, factor = 0.08) => (
@@ -3099,18 +3766,20 @@ function initSeatTwin() {
scene.add(fillLight); scene.add(fillLight);
const floor = new THREE.Mesh( const floor = new THREE.Mesh(
new THREE.PlaneGeometry(12, 9), new THREE.PlaneGeometry(30, 30),
new THREE.MeshStandardMaterial({ color: 0xdfe7e4, roughness: 0.86 }), new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 1,
depthWrite: false,
side: THREE.DoubleSide,
toneMapped: false,
}),
); );
floor.rotation.x = -Math.PI / 2; floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.16; floor.position.y = -0.16;
floor.receiveShadow = true; floor.renderOrder = 1;
scene.add(floor); 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); const vehicleSystems = createTwinCabin(THREE, scene);
applyTwinOutlineStyle(THREE, vehicleSystems.root, { applyTwinOutlineStyle(THREE, vehicleSystems.root, {
color: 0x4f747c, color: 0x4f747c,
@@ -3197,6 +3866,8 @@ function initSeatTwin() {
models, models,
fixedModels, fixedModels,
vehicleSystems, vehicleSystems,
floor,
fillLight,
selected: "passenger", selected: "passenger",
actuatorState: { passenger: initialDevice(), driver: initialDevice() }, actuatorState: { passenger: initialDevice(), driver: initialDevice() },
explicitAngleTargets: { passenger: {}, driver: {} }, explicitAngleTargets: { passenger: {}, driver: {} },
@@ -3204,9 +3875,12 @@ function initSeatTwin() {
dragging: false, dragging: false,
pointer: { x: 0, y: 0 }, pointer: { x: 0, y: 0 },
}; };
applyTwinVehicleTheme(vehicleSystems); applyTwinSceneTheme(state.seatTwin);
window.addEventListener("cognitive-agent:demo-role-change", (event) => { 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) loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback)
@@ -3215,6 +3889,7 @@ function initSeatTwin() {
state.seatTwin.referenceVehicle = vehicle; state.seatTwin.referenceVehicle = vehicle;
state.seatTwin.referenceReport = report; state.seatTwin.referenceReport = report;
state.seatTwin.referenceBounds = normalized.bounds; state.seatTwin.referenceBounds = normalized.bounds;
applyTwinSceneTheme(state.seatTwin);
fixedModels.rear.visible = false; fixedModels.rear.visible = false;
[["passenger", passengerCushion], ["driver", driverCushion]].forEach(([seat, referenceCushion]) => { [["passenger", passengerCushion], ["driver", driverCushion]].forEach(([seat, referenceCushion]) => {
if (!referenceCushion) return; if (!referenceCushion) return;
@@ -4856,9 +5531,11 @@ function renderDomainState(domainState) {
rear_right: "rearRightDoorState", rear_right: "rearRightDoorState",
}; };
let knownDoorCount = 0; let knownDoorCount = 0;
let lockedDoorCount = 0;
Object.entries(doorIds).forEach(([name, id]) => { Object.entries(doorIds).forEach(([name, id]) => {
const lockState = String(door?.locks?.[name]?.state || "unknown").toLowerCase(); const lockState = String(door?.locks?.[name]?.state || "unknown").toLowerCase();
if (lockState !== "unknown") knownDoorCount += 1; if (lockState !== "unknown") knownDoorCount += 1;
if (lockState === "lock") lockedDoorCount += 1;
$(`#${id}`).textContent = doorLabels[lockState] || "未知"; $(`#${id}`).textContent = doorLabels[lockState] || "未知";
const row = panel?.querySelector(`[data-door-state="${name}"]`); const row = panel?.querySelector(`[data-door-state="${name}"]`);
if (row) row.dataset.state = lockState; if (row) row.dataset.state = lockState;
@@ -4869,6 +5546,22 @@ function renderDomainState(domainState) {
$("#vehicleTelemetryState").textContent = hasPhysicalState $("#vehicleTelemetryState").textContent = hasPhysicalState
? (hasGear && hasSpeed && knownDoorCount === 4 ? "实时" : "部分") ? (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; 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" controlMode: sessionStorage.getItem("cognitive-agent.control-mode") === "gesture"
? "gesture" ? "gesture"
: "natural", : "natural",
activeModule: storedModule === "self-extension" activeModule: storedModule === "self-evolution"
? "self-evolution"
: storedModule === "self-extension"
? "self-extension" ? "self-extension"
: storedModule === "self-calibration" : storedModule === "self-calibration"
? "self-calibration" ? "self-calibration"
@@ -392,11 +394,11 @@
const naturalPanel = $("#v2NaturalPanel"); const naturalPanel = $("#v2NaturalPanel");
if (panelHeading) { if (panelHeading) {
panelHeading.classList.add("v2-operation-heading"); panelHeading.classList.add("v2-operation-heading", "v2-module-heading");
const kicker = $(".section-kicker", panelHeading); const kicker = $(".section-kicker", panelHeading);
const title = $("h2", panelHeading); const title = $("h2", panelHeading);
const intro = $(".panel-intro", panelHeading); const intro = $(".panel-intro", panelHeading);
if (kicker) kicker.textContent = "自然语言控制"; if (kicker) kicker.textContent = "SELF-ORCHESTRATION";
if (title) title.textContent = "一句话,调好整车"; if (title) title.textContent = "一句话,调好整车";
if (intro) intro.textContent = "座椅、空调、天幕与氛围灯,一句话联动完成"; if (intro) intro.textContent = "座椅、空调、天幕与氛围灯,一句话联动完成";
naturalPanel.append(panelHeading); naturalPanel.append(panelHeading);
@@ -417,8 +419,8 @@
} }
const gesturePanel = $("#v2GesturePanel"); const gesturePanel = $("#v2GesturePanel");
const gestureHeading = make("header", "v2-operation-heading v2-gesture-operation-heading", ` const gestureHeading = make("header", "v2-operation-heading v2-module-heading v2-gesture-operation-heading", `
<span class="section-kicker">手势控制</span> <span class="section-kicker">SELF-ORCHESTRATION</span>
<h2>抬抬手,控制整车</h2> <h2>抬抬手,控制整车</h2>
<p class="panel-intro">通过摄像头识别手势,直观控制车舱功能</p>`); <p class="panel-intro">通过摄像头识别手势,直观控制车舱功能</p>`);
gesturePanel.append(gestureHeading); gesturePanel.append(gestureHeading);
@@ -464,13 +466,13 @@
function buildModuleSwitcher(mainPanel) { function buildModuleSwitcher(mainPanel) {
const modules = [ const modules = [
{ id: "self-orchestration", label: "自编排", available: true },
{ {
id: "self-calibration", id: "self-calibration",
label: "自标定", label: "自标定",
available: true, available: true,
adminOnly: true, adminOnly: true,
}, },
{ id: "self-orchestration", label: "自编排", available: true },
{ id: "self-extension", label: "自扩展", available: true }, { id: "self-extension", label: "自扩展", available: true },
{ {
id: "self-repair", id: "self-repair",
@@ -478,6 +480,7 @@
available: true, available: true,
adminOnly: true, adminOnly: true,
}, },
{ id: "self-evolution", label: "自进化", available: true },
]; ];
const switcher = make("nav", "v2-module-switcher", ""); const switcher = make("nav", "v2-module-switcher", "");
switcher.id = "v2ModuleSwitcher"; switcher.id = "v2ModuleSwitcher";
@@ -501,6 +504,216 @@
mainPanel.prepend(switcher); 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) { function buildOutput(mainPanel) {
const output = make("section", "v2-output-shell", ` const output = make("section", "v2-output-shell", `
<header class="v2-output-heading"> <header class="v2-output-heading">
@@ -652,14 +865,17 @@
function setModule(module, { persist = true } = {}) { function setModule(module, { persist = true } = {}) {
if (["self-calibration", "self-repair"].includes(module) && ui.role !== "admin") { 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") { if (ui.role === "admin") {
allowed.add("self-calibration"); allowed.add("self-calibration");
allowed.add("self-repair"); allowed.add("self-repair");
} }
ui.activeModule = allowed.has(module) ? module : "self-orchestration"; ui.activeModule = allowed.has(module) ? module : "self-orchestration";
if (ui.activeModule === "self-extension" && ui.role !== "user") {
setRole("user");
}
if (persist) { if (persist) {
sessionStorage.setItem("cognitive-agent.active-module", ui.activeModule); sessionStorage.setItem("cognitive-agent.active-module", ui.activeModule);
} }
@@ -675,7 +891,7 @@
button.setAttribute("aria-selected", String(active)); button.setAttribute("aria-selected", String(active));
button.tabIndex = active ? 0 : -1; 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"); const drawer = $("#gestureDrawer");
if (drawer?.classList.contains("open")) $("#gestureCloseButton")?.click(); if (drawer?.classList.contains("open")) $("#gestureCloseButton")?.click();
} else { } else {
@@ -687,7 +903,10 @@
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 120); 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") { if (ui.activeModule === "self-repair" && role !== "admin") {
const roleSelect = $("#v2RoleSelect"); const roleSelect = $("#v2RoleSelect");
if (roleSelect) { if (roleSelect) {
@@ -946,6 +1165,7 @@
gestureDrawer, gestureDrawer,
); );
buildModuleSwitcher(mainPanel); buildModuleSwitcher(mainPanel);
buildEvolutionJournal(mainPanel);
$(".pipeline", mainPanel)?.classList.add("v2-legacy-hidden"); $(".pipeline", mainPanel)?.classList.add("v2-legacy-hidden");
$(".topbar")?.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" /> <link rel="icon" href="/icons/app-icon-192.png" />
<title>智舱随行 · 整车智能控制台</title> <title>智舱随行 · 整车智能控制台</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-9" /> <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="/console-v2.css?v=desktop-refactor-106" />
<link rel="stylesheet" href="/self-extension.css?v=extension-12" /> <link rel="stylesheet" href="/self-extension.css?v=extension-18" />
<link rel="stylesheet" href="/self-calibration.css?v=calibration-6" /> <link rel="stylesheet" href="/self-calibration.css?v=calibration-10" />
<link rel="stylesheet" href="/self-repair.css?v=repair-17" /> <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/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/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.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="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-1" 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 type="module" src="/gesture-control.js?v=android-pad-7"></script>
<script src="/pwa.js?v=android-pad-1" defer></script> <script src="/pwa.js?v=android-pad-1" defer></script>
<script src="/console-v2.js?v=desktop-refactor-30" defer></script> <script src="/console-v2.js?v=desktop-refactor-34" defer></script>
<script src="/self-extension.js?v=extension-18" defer></script> <script src="/self-extension.js?v=extension-20" defer></script>
<script src="/self-calibration.js?v=calibration-3" defer></script> <script src="/self-calibration.js?v=calibration-6" defer></script>
<script src="/self-repair.js?v=repair-17" defer></script> <script src="/self-repair.js?v=repair-20" defer></script>
<script src="/self-repair-seat3d.js?v=repair-17" defer></script> <script src="/self-repair-seat3d.js?v=repair-18" defer></script>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -393,7 +393,8 @@
<section class="twin-band" aria-labelledby="twinTitle"> <section class="twin-band" aria-labelledby="twinTitle">
<div class="twin-heading"> <div class="twin-heading">
<div> <div class="twin-title-copy">
<span class="section-kicker">LIVE VEHICLE DIGITAL TWIN</span>
<h3 id="twinTitle">整车孪生界面</h3> <h3 id="twinTitle">整车孪生界面</h3>
<div class="preset-indicator" id="presetIndicator" hidden> <div class="preset-indicator" id="presetIndicator" hidden>
<i data-lucide="bookmark-check"></i><span></span> <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%); 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 { .console-v2[data-module="self-calibration"] .v2-role-select > .v2-dropdown-trigger {
max-width: none; max-width: none;
gap: 5px; gap: 5px;
@@ -80,7 +62,7 @@
grid-row: 2; grid-row: 2;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
gap: 15px; gap: 15px;
padding: clamp(14px, 1.55vw, 24px) 0 0; padding: var(--v2-module-title-block) 0 0;
overflow: hidden; overflow: hidden;
} }
@@ -98,7 +80,7 @@
} }
.v2-calibration-mode-switch button { .v2-calibration-mode-switch button {
min-height: 52px; min-height: var(--v2-sidebar-item-height);
display: grid; display: grid;
grid-template-columns: 30px minmax(0, 1fr); grid-template-columns: 30px minmax(0, 1fr);
align-items: center; align-items: center;
@@ -123,14 +105,15 @@
.v2-calibration-mode-switch button:hover { .v2-calibration-mode-switch button:hover {
color: var(--v2-accent-strong); 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 { .v2-calibration-mode-switch button.active {
color: #111f20; color: #111f20;
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.94);
border-color: rgba(255, 255, 255, 0.86); border-color: color-mix(in srgb, var(--v2-accent) 18%, white);
box-shadow: 0 10px 28px rgba(30, 56, 107, 0.08); box-shadow: 0 10px 28px color-mix(in srgb, var(--v2-accent) 10%, transparent);
} }
.v2-calibration-mode-switch button.active svg { .v2-calibration-mode-switch button.active svg {
@@ -139,28 +122,30 @@
.v2-calibration-config-stack { .v2-calibration-config-stack {
display: grid; 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 { .v2-calibration-config-card {
min-width: 0; min-width: 0;
min-height: 52px; min-height: var(--v2-sidebar-card-height);
display: grid; display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto; grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
padding: 9px 11px; padding: 6px 8px;
color: #314565; color: #314565;
background: rgba(255, 255, 255, 0.52); background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(49, 94, 170, 0.11); border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 13px; border-radius: var(--v2-sidebar-card-radius);
} }
.v2-calibration-config-card > svg { .v2-calibration-config-card > svg {
width: 18px; width: 17px;
height: 18px; height: 17px;
justify-self: center; justify-self: center;
color: #5f7fb7; color: var(--v2-accent);
} }
.v2-calibration-config-card > div { .v2-calibration-config-card > div {
@@ -170,14 +155,15 @@
.v2-calibration-config-card small { .v2-calibration-config-card small {
color: #7786a2; color: #7786a2;
font-size: 9px; font-size: 8px;
font-weight: 700; font-weight: 700;
} }
.v2-calibration-config-card strong { .v2-calibration-config-card strong {
overflow: hidden; overflow: hidden;
color: #1e386b; color: #1e386b;
font-size: 12px; margin-top: 2px;
font-size: 11px;
font-weight: 850; font-weight: 850;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -207,7 +193,7 @@
width: 100%; width: 100%;
height: 18px; height: 18px;
color: #1e386b; color: #1e386b;
font-size: 12px; font-size: 11px;
font-weight: 850; font-weight: 850;
text-align: left; text-align: left;
} }
@@ -274,6 +260,7 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 18px; gap: 18px;
margin-inline: var(--v2-module-title-inline);
} }
.v2-calibration-heading > div:first-child { .v2-calibration-heading > div:first-child {
@@ -466,17 +453,32 @@
overflow: hidden; 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 { .v2-calibration-seat-scene .seat-model-host canvas {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
outline: 0; outline: 0;
cursor: grab; pointer-events: none;
touch-action: none; touch-action: none;
} }
.v2-calibration-seat-scene .seat-model-host canvas:active { .v2-calibration-seat-scene .v2-repair-seat-fallback {
cursor: grabbing; inset: 4px 18px 18px;
transform: scale(0.82);
}
.v2-calibration-seat-scene .repair-css-halo {
bottom: 10px;
} }
.v2-calibration-seat-scene .model-loading { .v2-calibration-seat-scene .model-loading {
@@ -1090,7 +1092,7 @@
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
display: grid; 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; gap: 10px;
} }
@@ -1108,8 +1110,7 @@
} }
.v2-calibration-live-grid .v2-calibration-stage > header, .v2-calibration-live-grid .v2-calibration-stage > header,
.v2-calibration-live-grid .v2-calibration-video-panel > header, .v2-calibration-live-grid .v2-calibration-video-panel > header {
.v2-calibration-live-grid .v2-calibration-mqtt-panel > header {
align-items: center; align-items: center;
} }
@@ -1460,6 +1461,37 @@
margin-top: auto; 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) { @media (max-width: 1180px) {
.v2-calibration-hero-grid { .v2-calibration-hero-grid {
grid-template-columns: minmax(0, 1.5fr) minmax(245px, 0.75fr); grid-template-columns: minmax(0, 1.5fr) minmax(245px, 0.75fr);
@@ -1474,7 +1506,7 @@
} }
.v2-calibration-live-grid { .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 { .v2-calibration-live-axes .v2-calibration-axis-grid {
+23 -115
View File
@@ -4,8 +4,8 @@
const API_ROOT = "/api/calibration"; const API_ROOT = "/api/calibration";
const TARGET_KEY = "cognitive-agent.calibration-target"; const TARGET_KEY = "cognitive-agent.calibration-target";
const pageTitles = { const pageTitles = {
live: ["SEAT · ZED JOINT CALIBRATION", "座椅—双目联合标定", "座椅姿态、四轴 Hall、ZED 画面与双目位姿同步更新"], live: ["SELF-CALIBRATION", "座椅与双目联合标定", "同步标定座椅姿态、四轴 Hall 与 ZED 实时画面"],
system: ["CONNECTION SETTINGS", "连接与运行设置", "CAN、MQTT 与 RTSP 接入参数"], system: ["SELF-CALIBRATION · SETTINGS", "连接与运行设置", "配置 CAN、MQTT 与 RTSP 接入参数"],
}; };
const phaseLabels = { const phaseLabels = {
0: "待命", 0: "待命",
@@ -25,7 +25,6 @@
let reconnectTimer = 0; let reconnectTimer = 0;
let toastTimer = 0; let toastTimer = 0;
let settingsHydrated = false; let settingsHydrated = false;
let modelRequested = false;
let activeView = "live"; let activeView = "live";
function buildShell() { function buildShell() {
@@ -83,10 +82,10 @@
root.setAttribute("aria-label", "N7 自标定工作台"); root.setAttribute("aria-label", "N7 自标定工作台");
root.innerHTML = ` root.innerHTML = `
<header class="v2-calibration-heading"> <header class="v2-calibration-heading">
<div> <div class="v2-module-heading">
<span class="section-kicker" id="calibrationPageKicker">SEAT · ZED JOINT CALIBRATION</span> <span class="section-kicker" id="calibrationPageKicker">SELF-CALIBRATION</span>
<h1 id="calibrationPageTitle">座椅—双目联合标定</h1> <h1 id="calibrationPageTitle">座椅与双目联合标定</h1>
<p id="calibrationPageDescription">座椅姿态、四轴 Hall、ZED 画面与双目位姿同步更新</p> <p id="calibrationPageDescription">同步标定座椅姿态、四轴 Hall 与 ZED 实时画面</p>
</div> </div>
<div class="v2-calibration-statuses"> <div class="v2-calibration-statuses">
<div><i id="calibrationCanDot"></i><span><small>CAN</small><strong id="calibrationCanState">未连接</strong></span></div> <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> <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-seat-scene">
<div class="v2-calibration-floor-grid"></div> <div class="v2-calibration-floor-grid"></div>
<div id="seatModelHost" class="seat-model-host"> <div id="calibrationSeatViewer" class="seat-model-host calibration-cartoon-viewer">
<canvas id="seatCanvas" aria-label="N7 原始三维座椅模型,可拖动旋转并滚轮缩放"></canvas> <canvas id="calibrationSeatCanvas" aria-label="自标定卡通风格三维座椅,可拖动旋转并滚轮缩放"></canvas>
<div id="seatModelLoading" class="model-loading"> <div id="seatModelLoading" class="model-loading">
<span class="model-spinner"></span> <span class="model-spinner"></span>
<strong>正在加载原始 N7 座椅</strong> <strong>正在构建座椅数字孪生</strong>
<small>ANDROID GLB · LOCAL</small> <small>CALIBRATION · CARTOON TWIN</small>
</div> </div>
<div class="orbit-hint">拖动旋转 · 滚轮缩放 · 双击复位</div> <div class="orbit-hint">拖动旋转 · 滚轮缩放 · 双击复位</div>
</div> </div>
@@ -127,17 +126,6 @@
<div class="v2-calibration-actions"><button id="calibrationVideoStart" type="button">开始接收</button><button id="calibrationVideoStop" type="button">停止</button></div> <div class="v2-calibration-actions"><button id="calibrationVideoStart" type="button">开始接收</button><button id="calibrationVideoStop" type="button">停止</button></div>
</section> </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> </div>
<section class="v2-calibration-panel v2-calibration-axis-section v2-calibration-live-axes"> <section class="v2-calibration-panel v2-calibration-axis-section v2-calibration-live-axes">
@@ -312,9 +300,7 @@
renderWorkflow(state.operation.phase); renderWorkflow(state.operation.phase);
renderAxes(state.axes, controller.job_active, transport.state === "online"); renderAxes(state.axes, controller.job_active, transport.state === "online");
renderSeat(state.axes); renderSeat(state.axes);
renderMqtt(mqtt);
renderVideo(state.video); renderVideo(state.video);
renderMappings(state.axes, mqtt.areas);
$("#calibrationStartAll").disabled = $("#calibrationStartAll").disabled =
transport.state !== "online" || controller.job_active; transport.state !== "online" || controller.job_active;
if (!settingsHydrated) hydrateSettings(state.settings, state.video.ffmpeg_available); if (!settingsHydrated) hydrateSettings(state.settings, state.video.ffmpeg_available);
@@ -408,55 +394,25 @@
}; };
window.n7SeatPose = pose; window.n7SeatPose = pose;
window.dispatchEvent(new CustomEvent("n7-seat-pose", { detail: 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`; $("#calibrationTagLong").textContent = `${longitudinal.physical_value.toFixed(2)} cm`;
$("#calibrationTagLeg").textContent = `${legrest.physical_value.toFixed(1)}°`; $("#calibrationTagLeg").textContent = `${legrest.physical_value.toFixed(1)}°`;
$("#calibrationTagBack").textContent = `${backrest.physical_value.toFixed(1)}°`; $("#calibrationTagBack").textContent = `${backrest.physical_value.toFixed(1)}°`;
$("#calibrationTagHeight").textContent = `${height.physical_value.toFixed(2)} cm`; $("#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) { function renderVideo(video) {
const friendly = { idle: "未启动", connecting: "连接中", playing: "播放中", error: "异常" }; const friendly = { idle: "未启动", connecting: "连接中", playing: "播放中", error: "异常" };
$("#calibrationVideoBadge").textContent = friendly[video.state] || video.state; $("#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) { function hydrateSettings(settings, ffmpegAvailable) {
settingsHydrated = true; settingsHydrated = true;
$("#calibrationCanInterface").value = settings.can.interface; $("#calibrationCanInterface").value = settings.can.interface;
@@ -580,19 +514,6 @@
}, settings.can.mode === "simulation" ? "标定仿真台架已连接" : "真实台架连接完成"); }, 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() { function closeEvents() {
window.clearTimeout(reconnectTimer); window.clearTimeout(reconnectTimer);
reconnectTimer = 0; reconnectTimer = 0;
@@ -621,7 +542,6 @@
closeEvents(); closeEvents();
return; return;
} }
loadSeatModel();
fetchState().catch((error) => toast(`自标定服务连接失败:${error.message}`, true)); fetchState().catch((error) => toast(`自标定服务连接失败:${error.message}`, true));
connectEvents(); connectEvents();
} }
@@ -641,18 +561,6 @@
() => api("/start", { axis: "all" }), () => 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( $("#calibrationVideoStart").addEventListener("click", () => run(
() => api("/video/start"), () => api("/video/start"),
"正在启动 RTSP 接收", "正在启动 RTSP 接收",
+38 -25
View File
@@ -109,7 +109,7 @@
} }
.v2-extension-mode-switch button { .v2-extension-mode-switch button {
min-height: 52px; min-height: var(--v2-sidebar-item-height);
display: grid; display: grid;
grid-template-columns: 30px minmax(0, 1fr); grid-template-columns: 30px minmax(0, 1fr);
align-items: center; align-items: center;
@@ -133,14 +133,15 @@
.v2-extension-mode-switch button:hover { .v2-extension-mode-switch button:hover {
color: var(--v2-accent-strong); 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 { .v2-extension-mode-switch button.active {
color: #111f20; color: #111f20;
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.94);
border-color: rgba(255, 255, 255, 0.86); border-color: color-mix(in srgb, var(--v2-accent) 18%, white);
box-shadow: 0 10px 28px rgba(34, 93, 92, 0.08); box-shadow: 0 10px 28px color-mix(in srgb, var(--v2-accent) 10%, transparent);
} }
.v2-extension-mode-switch button.active svg { .v2-extension-mode-switch button.active svg {
@@ -161,7 +162,7 @@
.v2-extension-config-card { .v2-extension-config-card {
min-width: 0; min-width: 0;
min-height: 43px; min-height: var(--v2-sidebar-card-height);
display: grid; display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto; grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center; align-items: center;
@@ -169,7 +170,7 @@
padding: 6px 8px; padding: 6px 8px;
background: rgba(255, 255, 255, 0.72); background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.78); border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 10px; border-radius: var(--v2-sidebar-card-radius);
} }
.v2-extension-config-card > svg { .v2-extension-config-card > svg {
@@ -252,11 +253,12 @@
} }
.v2-extension-view { .v2-extension-view {
--v2-extension-view-pad: clamp(13px, 1.7vw, 24px);
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
min-height: 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; overflow: hidden;
background: background:
radial-gradient(circle at 31% -10%, rgba(216, 244, 241, 0.72), transparent 26%), radial-gradient(circle at 31% -10%, rgba(216, 244, 241, 0.72), transparent 26%),
@@ -295,7 +297,7 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 20px;
padding: 0 3px; padding-inline: max(0px, calc(var(--v2-module-title-inline) - var(--v2-extension-view-pad)));
} }
.v2-extension-heading h1 { .v2-extension-heading h1 {
@@ -866,6 +868,20 @@ button.v2-extension-capability.preview {
border-radius: 11px; border-radius: 11px;
text-align: left; text-align: left;
cursor: pointer; 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 { .v2-extension-learned-action > span {
@@ -1117,13 +1133,9 @@ button.v2-extension-capability.preview {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
gap: 9px; overflow: hidden;
padding: clamp(11px, 1.2vw, 16px); border-radius: 17px;
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);
} }
.v2-extension-control > .v2-extension-control-grid, .v2-extension-control > .v2-extension-control-grid,
@@ -1132,20 +1144,20 @@ button.v2-extension-capability.preview {
} }
.v2-extension-control > .v2-extension-control-grid { .v2-extension-control > .v2-extension-control-grid {
grid-column: 1;
}
.v2-extension-control > .v2-extension-twin-board {
grid-column: 2; grid-column: 2;
} }
.v2-extension-control > #extensionVehicleStateSummary { .v2-extension-control > .v2-extension-twin-board {
grid-column: 1; grid-column: 1;
}
.v2-extension-control > #extensionVehicleStateSummary {
grid-column: 2;
grid-row: 4; grid-row: 4;
} }
.v2-extension-control > #extensionRunPlan { .v2-extension-control > #extensionRunPlan {
grid-column: 2; grid-column: 1;
grid-row: 4; grid-row: 4;
} }
@@ -1808,7 +1820,7 @@ form > .v2-extension-primary {
@media (max-height: 820px) { @media (max-height: 820px) {
.console-v2[data-module="self-extension"] .main-panel { .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 { .console-v2[data-module="self-extension"] .v2-extension-sidebar {
@@ -1840,7 +1852,8 @@ form > .v2-extension-primary {
} }
.v2-extension-view { .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 { .v2-extension-heading h1 {
@@ -1934,8 +1947,8 @@ form > .v2-extension-primary {
} }
.v2-extension-twin-board { .v2-extension-twin-board {
gap: 5px; gap: 0;
padding: 7px; padding: 0;
} }
.v2-extension-run-plan { .v2-extension-run-plan {
+3 -7
View File
@@ -465,7 +465,7 @@
root.innerHTML = ` root.innerHTML = `
<section class="v2-extension-view v2-extension-study" data-extension-view="study"> <section class="v2-extension-view v2-extension-study" data-extension-view="study">
<header class="v2-extension-heading"> <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"> <div class="v2-extension-heading-actions">
<button class="v2-extension-reset-action" type="button" id="extensionResetButton"><i data-lucide="rotate-ccw"></i>重置学习</button> <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> <button type="button" data-add-type="light"><i data-lucide="plus"></i>添加玩具</button>
@@ -475,7 +475,7 @@
</section> </section>
<section class="v2-extension-view v2-extension-control" data-extension-view="control" hidden> <section class="v2-extension-view v2-extension-control" data-extension-view="control" hidden>
<header class="v2-extension-heading"> <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> </header>
<div class="v2-extension-command-row"> <div class="v2-extension-command-row">
<div class="v2-extension-composer"> <div class="v2-extension-composer">
@@ -497,11 +497,7 @@
<div class="v2-extension-vehicle-grid" id="extensionVehicleGrid"></div> <div class="v2-extension-vehicle-grid" id="extensionVehicleGrid"></div>
</section> </section>
</div> </div>
<section class="v2-extension-twin-board" aria-labelledby="extensionTwinTitle"> <section class="v2-extension-twin-board" aria-label="整车孪生界面">
<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>
<div class="v2-extension-twin-host" id="extensionTwinHost"> <div class="v2-extension-twin-host" id="extensionTwinHost">
<div class="v2-extension-twin-waiting"><i data-lucide="loader-circle"></i><span>正在挂载整车孪生界面</span></div> <div class="v2-extension-twin-waiting"><i data-lucide="loader-circle"></i><span>正在挂载整车孪生界面</span></div>
</div> </div>
+53 -15
View File
@@ -1,13 +1,27 @@
(function () { (function () {
"use strict"; "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 THREE = window.THREE;
const canvas = document.getElementById("repairSeatCanvas"); const canvas = document.getElementById(canvasId);
const viewer = document.getElementById("repairSeatViewer"); const viewer = document.getElementById(viewerId);
if (!canvas || !viewer || viewer.dataset.seat3dInitialized === "true") return; if (!canvas || !viewer || viewer.dataset.seat3dInitialized === "true") return;
viewer.dataset.seat3dInitialized = "true"; viewer.dataset.seat3dInitialized = "true";
const markReady = (fallback = false) => {
viewer.classList.add(readyClass);
if (fallback) viewer.classList.add("fallback");
};
const buildFallback = () => { const buildFallback = () => {
const fallback = document.createElement("div"); const fallback = document.createElement("div");
fallback.className = "v2-repair-seat-fallback"; fallback.className = "v2-repair-seat-fallback";
@@ -23,19 +37,19 @@
</div> </div>
<span class="repair-css-halo"></span>`; <span class="repair-css-halo"></span>`;
viewer.insertBefore(fallback, canvas.nextSibling); viewer.insertBefore(fallback, canvas.nextSibling);
window.addEventListener("seat-healing-state", (event) => { window.addEventListener(stateEvent, (event) => {
const detail = event.detail || {}; const detail = event.detail || {};
const axes = new Map((detail.axes || []).map((axis) => [Number(axis.motor), Number(axis.position_pct || 0)])); 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-slide", `${(50 - (axes.get(0) ?? initialAxes[0])) * 0.18}px`);
fallback.style.setProperty("--repair-lift", `${((axes.get(1) ?? 42) - 50) * 0.1}px`); fallback.style.setProperty("--repair-lift", `${((axes.get(1) ?? initialAxes[1]) - 50) * 0.1}px`);
fallback.style.setProperty("--repair-back", `${-8 - (axes.get(2) ?? 48) * 0.24}deg`); fallback.style.setProperty("--repair-back", `${-8 - (axes.get(2) ?? initialAxes[2]) * 0.24}deg`);
fallback.style.setProperty("--repair-leg", `${-5 + (axes.get(3) ?? 20) * 0.58}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("heating", Number(detail.heatingLevel || 0) > 0);
fallback.classList.toggle("ventilating", Boolean(detail.ventilation)); fallback.classList.toggle("ventilating", Boolean(detail.ventilation));
fallback.classList.toggle("active", !["IDLE", "PASSED"].includes(detail.state)); fallback.classList.toggle("active", !["IDLE", "PASSED"].includes(detail.state));
fallback.classList.toggle("failed", detail.state === "FAILED"); fallback.classList.toggle("failed", detail.state === "FAILED");
}); });
viewer.classList.add("ready", "fallback"); markReady(true);
}; };
if (!THREE) { if (!THREE) {
@@ -340,8 +354,8 @@
failed: { shell: 0x7a3f43, insert: 0xc97a78, bolster: 0x85494c, emissive: 0x4a100d }, 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 current = { axes: [...initialAxes], ventilation: false, heating: 0, active: false, state: "IDLE" };
const desired = { axes: [35, 42, 48, 20], ventilation: false, heating: 0, active: false, state: "IDLE" }; const desired = { axes: [...initialAxes], ventilation: false, heating: 0, active: false, state: "IDLE" };
function updateCamera() { function updateCamera() {
const cosPitch = Math.cos(orbit.pitch); const cosPitch = Math.cos(orbit.pitch);
@@ -399,7 +413,13 @@
current.state = desired.state; current.state = desired.state;
slideRoot.position.z = THREE.MathUtils.mapLinear(current.axes[0], 0, 100, -0.46, 0.46); 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); 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); legrestPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[3], 0, 100, 0.08, -0.68);
@@ -463,18 +483,36 @@
updateCamera(); updateCamera();
}, { passive: false }); }, { passive: false });
viewer.addEventListener("dblclick", resetOrbit); viewer.addEventListener("dblclick", resetOrbit);
window.addEventListener("seat-healing-state", onHealingState); window.addEventListener(stateEvent, onHealingState);
window.addEventListener("self-repair-seat-reset", resetOrbit); window.addEventListener(resetEvent, resetOrbit);
if (window.ResizeObserver) new ResizeObserver(resize).observe(viewer); if (window.ResizeObserver) new ResizeObserver(resize).observe(viewer);
window.addEventListener("resize", resize); window.addEventListener("resize", resize);
resize(); resize();
resetOrbit(); resetOrbit();
animate(); 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") { if (document.readyState === "complete") {
init(); init();
} else { } else {
+213 -126
View File
@@ -46,7 +46,7 @@
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
margin-top: clamp(20px, 3vh, 36px); margin-top: clamp(22px, 3.5vh, 42px);
overflow: hidden; overflow: hidden;
} }
@@ -70,7 +70,7 @@
grid-row: 2; grid-row: 2;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
gap: 11px; gap: 11px;
padding: clamp(12px, 1.35vw, 20px) 0 0; padding: var(--v2-module-title-block) 0 0;
overflow: hidden; overflow: hidden;
} }
@@ -88,12 +88,12 @@
} }
.v2-repair-view-switch button { .v2-repair-view-switch button {
min-height: 58px; min-height: var(--v2-sidebar-item-height);
display: grid; display: grid;
grid-template-columns: 30px minmax(0, 1fr); grid-template-columns: 30px minmax(0, 1fr);
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 8px 13px; padding: 6px 15px;
color: color-mix(in srgb, var(--v2-accent-strong) 56%, #65787a); color: color-mix(in srgb, var(--v2-accent-strong) 56%, #65787a);
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
@@ -105,15 +105,15 @@
.v2-repair-view-switch button:hover { .v2-repair-view-switch button:hover {
color: var(--v2-accent-strong); color: var(--v2-accent-strong);
background: color-mix(in srgb, var(--v2-accent-soft) 68%, white); background: color-mix(in srgb, var(--v2-accent-soft) 34%, white);
border-color: color-mix(in srgb, var(--v2-accent) 14%, white); border-color: color-mix(in srgb, var(--v2-accent) 10%, white);
} }
.v2-repair-view-switch button.active { .v2-repair-view-switch button.active {
color: var(--v2-accent-strong); color: #111f20;
background: color-mix(in srgb, var(--v2-accent-soft) 82%, white); background: rgba(255, 255, 255, 0.94);
border-color: color-mix(in srgb, var(--v2-accent) 24%, white); border-color: color-mix(in srgb, var(--v2-accent) 18%, white);
box-shadow: 0 10px 28px color-mix(in srgb, var(--v2-accent) 14%, transparent); box-shadow: 0 10px 28px color-mix(in srgb, var(--v2-accent) 10%, transparent);
} }
.v2-repair-view-switch button > svg { .v2-repair-view-switch button > svg {
@@ -124,7 +124,7 @@
} }
.v2-repair-view-switch button.active > svg { .v2-repair-view-switch button.active > svg {
color: var(--v2-accent-strong); color: var(--v2-accent);
} }
.v2-repair-view-switch button > span { .v2-repair-view-switch button > span {
@@ -142,11 +142,12 @@
.v2-repair-view-switch strong { .v2-repair-view-switch strong {
font-size: 15px; font-size: 15px;
line-height: 1.2;
} }
.v2-repair-view-switch small { .v2-repair-view-switch small {
color: color-mix(in srgb, var(--v2-accent) 34%, #829092); color: color-mix(in srgb, var(--v2-accent) 34%, #829092);
font-size: 10px; font-size: 9px;
font-weight: 600; font-weight: 600;
} }
@@ -156,21 +157,23 @@
.v2-repair-config-stack { .v2-repair-config-stack {
display: grid; 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 { .v2-repair-config-card {
min-width: 0; min-width: 0;
min-height: 50px; min-height: var(--v2-sidebar-card-height);
display: grid; display: grid;
grid-template-columns: 27px minmax(0, 1fr) auto; grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 9px; gap: 8px;
padding: 8px 10px; padding: 6px 8px;
color: var(--repair-ink); color: var(--repair-ink);
background: rgba(255, 255, 255, 0.52); background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--repair-line); border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 13px; border-radius: var(--v2-sidebar-card-radius);
} }
.v2-repair-config-card > svg { .v2-repair-config-card > svg {
@@ -187,21 +190,25 @@
.v2-repair-config-card small { .v2-repair-config-card small {
color: var(--repair-muted); color: var(--repair-muted);
font-size: 10px; font-size: 8px;
font-weight: 650; font-weight: 650;
} }
.v2-repair-config-card strong { .v2-repair-config-card strong {
overflow: hidden; overflow: hidden;
color: var(--repair-heading); color: var(--repair-heading);
font-size: 13px; margin-top: 2px;
font-size: 11px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.v2-repair-config-card > span { .v2-repair-config-card > span {
padding: 3px 5px;
color: var(--v2-accent-strong); 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, .repair-execution-card .v2-config-card-select,
@@ -210,12 +217,12 @@
} }
.repair-execution-card .v2-dropdown-trigger { .repair-execution-card .v2-dropdown-trigger {
min-height: 21px; min-height: 18px;
padding: 0; padding: 0;
color: var(--repair-heading); color: var(--repair-heading);
background: transparent; background: transparent;
border: 0; border: 0;
font-size: 13px; font-size: 11px;
font-weight: 800; font-weight: 800;
box-shadow: none; box-shadow: none;
} }
@@ -231,9 +238,10 @@
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: end; align-items: start;
gap: 16px; gap: 16px;
padding: 0 2px; margin-inline: var(--v2-module-title-inline);
padding: 0;
} }
.v2-repair-heading h1 { .v2-repair-heading h1 {
@@ -416,6 +424,13 @@
transform: translateY(0); transform: translateY(0);
} }
.v2-repair-toolbar button.danger {
color: #a63f47;
background: #fff0f1;
border-color: #efc4c7;
box-shadow: none;
}
.v2-repair-toolbar button:focus-visible { .v2-repair-toolbar button:focus-visible {
outline: 2px solid color-mix(in srgb, var(--v2-accent) 42%, white); outline: 2px solid color-mix(in srgb, var(--v2-accent) 42%, white);
outline-offset: 2px; outline-offset: 2px;
@@ -438,8 +453,8 @@
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-areas: "twin cases timeline"; grid-template-areas: "twin cases logs";
grid-template-columns: minmax(280px, 1.18fr) minmax(185px, 0.66fr) minmax(330px, 1.32fr); grid-template-columns: minmax(280px, 1.18fr) minmax(210px, 0.72fr) minmax(360px, 1.4fr);
gap: 9px; gap: 9px;
overflow: hidden; overflow: hidden;
} }
@@ -482,34 +497,17 @@
.v2-repair-cases-panel { .v2-repair-cases-panel {
grid-area: cases; grid-area: cases;
display: grid; display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr) auto;
} }
.v2-repair-overall-progress { .v2-repair-injection-note {
height: 8px; margin: 9px 10px 6px;
margin: 10px 12px 5px; padding: 8px 9px;
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;
color: var(--repair-muted); color: var(--repair-muted);
font-size: 9px; background: color-mix(in srgb, var(--v2-accent-soft) 66%, white);
text-overflow: ellipsis; border-radius: 9px;
white-space: nowrap; font-size: 8px;
line-height: 1.5;
} }
.v2-repair-case-list { .v2-repair-case-list {
@@ -517,7 +515,7 @@
display: grid; display: grid;
align-content: start; align-content: start;
gap: 6px; gap: 6px;
padding: 0 10px 10px; padding: 0 10px 8px;
overflow: auto; overflow: auto;
} }
@@ -538,9 +536,9 @@
} }
.v2-repair-case-list button.selected { .v2-repair-case-list button.selected {
background: #fff; background: color-mix(in srgb, var(--v2-accent-soft) 76%, white);
border-color: color-mix(in srgb, var(--v2-accent) 22%, white); border-color: color-mix(in srgb, var(--v2-accent) 72%, white);
box-shadow: 0 7px 20px color-mix(in srgb, var(--v2-accent) 10%, transparent); box-shadow: none;
} }
.v2-repair-case-list button > span { .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="complete"] > i { background: var(--repair-deep-blue); }
.v2-repair-case-list button[data-state="failed"] > i { background: var(--repair-danger); } .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-twin-panel,
.v2-repair-timeline-panel { .v2-repair-log-panel {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
} }
.v2-repair-twin-panel { grid-area: twin; } .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 { .v2-repair-seat-viewer {
min-height: 0; min-height: 0;
@@ -911,88 +954,94 @@
white-space: nowrap; white-space: nowrap;
} }
.v2-repair-timeline { .v2-repair-log-panel > header > b {
min-height: 0; padding: 6px 10px;
padding: 9px 11px 10px; border-radius: 999px;
overflow: auto; font-size: 11px;
} }
.v2-repair-timeline article { .v2-repair-log-panel > header h2 { font-size: 16px; }
min-height: 70px;
position: relative; .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; display: grid;
grid-template-columns: 18px minmax(0, 1fr); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px; gap: 6px;
padding: 9px 10px;
border-bottom: 1px solid var(--repair-line);
} }
.v2-repair-timeline article:not(:last-child)::before { .v2-repair-log-summary > div {
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 {
min-width: 0; min-width: 0;
padding-bottom: 8px; padding: 9px 10px;
background: var(--repair-soft);
border-radius: 9px;
} }
.v2-repair-timeline article header { .v2-repair-log-summary small { display: block; overflow: hidden; color: var(--repair-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
min-height: 24px; .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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding: 0 3px; padding: 5px 10px;
border-bottom: 1px solid var(--repair-line);
} }
.v2-repair-timeline article header strong { .v2-repair-log-toolbar > div { display: flex; gap: 3px; }
color: var(--repair-ink); .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; }
font-size: 12px; .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-event-stream article {
.v2-repair-timeline article[data-state="active"] header strong { color: var(--v2-accent); } min-height: 88px;
.v2-repair-timeline article[data-state="failed"] header strong { color: var(--repair-danger); } position: relative;
display: grid;
.v2-repair-timeline article header span { grid-template-columns: 78px 14px minmax(0, 1fr);
color: #909b9d; gap: 8px;
font-size: 8px; padding: 11px 0;
font-weight: 750; border-bottom: 1px solid var(--repair-line);
} }
.v2-repair-timeline article p { .v2-repair-event-stream article > time { padding-top: 3px; color: var(--repair-muted); font: 9px "IBM Plex Mono", Consolas, monospace; }
min-height: 38px; .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); }
margin: 0; .v2-repair-event-stream article:not(:last-child)::after { width: 1px; position: absolute; top: 27px; bottom: -2px; left: 91px; content: ""; background: var(--repair-line); }
padding: 8px 9px; .v2-repair-event-stream article[data-kind="fault"] > i { background: var(--repair-danger); }
overflow: hidden; .v2-repair-event-stream article[data-kind="diagnosis"] > i,
color: var(--repair-muted); .v2-repair-event-stream article[data-kind="repair"] > i { background: var(--repair-warn); }
background: var(--repair-soft); .v2-repair-event-stream article[data-kind="success"] > i,
border: 1px solid var(--repair-line); .v2-repair-event-stream article[data-kind="heartbeat"] > i { background: #248b67; }
border-radius: 9px; .v2-repair-event-stream article > div { min-width: 0; }
font-size: 9px; .v2-repair-event-stream article header { min-height: 21px; display: flex; align-items: center; gap: 8px; padding: 0; border: 0; }
line-height: 1.45; .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"] { .v2-repair-view[data-repair-page="hardware"] {
overflow: auto; overflow: auto;
@@ -1280,10 +1329,48 @@
.v2-repair-event-list strong { font-size: 9px; } .v2-repair-event-list strong { font-size: 9px; }
.v2-repair-event-list span { color: #758587; font-size: 8px; } .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) { @media (max-width: 1220px) {
.v2-repair-heading { grid-template-columns: minmax(0, 1fr); } .v2-repair-heading { grid-template-columns: minmax(0, 1fr); }
.v2-repair-status-grid { display: none; } .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 { padding-inline: 8px; }
.v2-repair-toolbar button svg { display: none; } .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); } .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; } .console-v2[data-module="self-repair"] .v2-self-repair { overflow: auto; }
.v2-repair-view { min-height: 900px; overflow: visible; } .v2-repair-view { min-height: 900px; overflow: visible; }
.v2-repair-software-grid { .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-columns: minmax(330px, 1fr) 210px;
grid-template-rows: 410px 390px; grid-template-rows: 410px 390px;
} }
+246 -88
View File
@@ -4,16 +4,6 @@
const API_ROOT = "/api/self-repair"; const API_ROOT = "/api/self-repair";
const VIEW_KEY = "cognitive-agent.self-repair-view"; const VIEW_KEY = "cognitive-agent.self-repair-view";
const MODE_KEY = "cognitive-agent.self-repair-execution"; 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 = { const STATE_TEXT = {
OK: "正常", OK: "正常",
WARN: "告警", WARN: "告警",
@@ -23,13 +13,67 @@
NOT_USED: "未启用", NOT_USED: "未启用",
IDLE: "检测中", IDLE: "检测中",
}; };
const TIMELINE = [ const EVENT_META = {
{ title: "开始正常任务", phases: ["BASELINE"] }, BASELINE: { kind: "system", label: "运行" },
{ title: "故障发生并停止", phases: ["INJECTED"] }, INJECTED: { kind: "fault", label: "注入" },
{ title: "AI判断原因", phases: ["DIAGNOSED"] }, DIAGNOSED: { kind: "diagnosis", label: "诊断" },
{ title: "执行恢复策略", phases: ["REPAIRING"] }, REPAIRING: { kind: "repair", label: "修复" },
{ title: "继续原任务", phases: ["VERIFYING", "PASSED"] }, 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.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
@@ -39,7 +83,7 @@
.replaceAll(">", "&gt;") .replaceAll(">", "&gt;")
.replaceAll('"', "&quot;"); .replaceAll('"', "&quot;");
let latest = null; let latest = fallbackState();
let selectedCaseId = null; let selectedCaseId = null;
let selectedPinId = "JC1-1"; let selectedPinId = "JC1-1";
let activeView = sessionStorage.getItem(VIEW_KEY) === "hardware" let activeView = sessionStorage.getItem(VIEW_KEY) === "hardware"
@@ -51,6 +95,7 @@
let polling = false; let polling = false;
let toastTimer = 0; let toastTimer = 0;
let confirmationResolve = null; let confirmationResolve = null;
let eventFilter = "all";
function isAdmin() { function isAdmin() {
return window.CognitiveAgentConsoleV2?.getRole() === "admin" return window.CognitiveAgentConsoleV2?.getRole() === "admin"
@@ -118,7 +163,7 @@
root.setAttribute("aria-label", "座椅 AI 自修复验证台"); root.setAttribute("aria-label", "座椅 AI 自修复验证台");
root.innerHTML = ` root.innerHTML = `
<header class="v2-repair-heading"> <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 class="v2-repair-status-grid">
<div><i id="repairCanDot"></i><span><small>CAN桥接</small><strong id="repairCanStatus">离线</strong></span></div> <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> <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"> <section class="v2-repair-view" data-repair-page="software">
<header class="v2-repair-toolbar"> <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> <div>
<button type="button" id="repairSoftwareRecords"><i data-lucide="history"></i>验证记录</button> <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="repairFirmwareFlash"><i data-lucide="microchip"></i>构建/烧录</button>
<button type="button" id="repairReset"><i data-lucide="rotate-ccw"></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="repairRuntimeToggle"><i data-lucide="play"></i>启动座椅运行</button>
<button class="primary" type="button" id="repairStart"><i data-lucide="play"></i>启动当前用例</button>
</div> </div>
</header> </header>
<div class="v2-repair-software-grid"> <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"> <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> <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"> <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 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> </div>
</section> </section>
<section class="v2-repair-panel v2-repair-timeline-panel"> <section class="v2-repair-panel v2-repair-cases-panel">
<header><div><span class="section-kicker">HEALING FLOW</span><h2>座椅任务自修复</h2></div><b id="repairElapsed">0.000 s</b></header> <header><div><span class="section-kicker">FAULT INJECTION</span><h2>量产故障注入</h2></div><b id="repairCaseCount">6 个用例</b></header>
<div class="v2-repair-timeline" id="repairTimeline"></div> <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> </section>
</div> </div>
</section> </section>
@@ -240,36 +300,92 @@
|| latest.scenarios[0]; || latest.scenarios[0];
} }
function timelineDetail(index, scenario, healing) { function formatDuration(seconds) {
const telemetry = healing.telemetry || {}; const whole = Math.max(0, Math.floor(Number(seconds || 0)));
const details = [ const hours = Math.floor(whole / 3600);
scenario.normal_task, const minutes = Math.floor((whole % 3600) / 60);
telemetry.bench_reaction || scenario.fault_reaction, const remainder = whole % 60;
scenario.diagnosis, return [hours, minutes, remainder]
scenario.repair, .map((value) => String(value).padStart(2, "0"))
scenario.recovery_reaction, .join(":");
];
const phases = TIMELINE[index].phases;
const event = [...(healing.events || [])].reverse()
.find((item) => phases.includes(item.phase));
return event?.detail || event?.title || details[index] || "等待运行日志";
} }
function renderTimeline() { function formatEventTime(epoch) {
const healing = latest.healing; if (!epoch) return "--:--:--.---";
const scenario = scenarioForDisplay(); const value = new Date(Number(epoch) * 1000);
if (!scenario) return; const base = [value.getHours(), value.getMinutes(), value.getSeconds()]
const phaseIndex = PHASE_INDEX[healing.phase] ?? -1; .map((item) => String(item).padStart(2, "0"))
const passed = healing.phase === "PASSED"; .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"; const failed = healing.phase === "FAILED";
$("#repairTimeline").innerHTML = TIMELINE.map((stage, index) => { const repairing = activeRepair;
let state = "pending"; const logState = $("#repairLogState");
if (passed || phaseIndex > index) state = "complete"; logState.textContent = failed ? "需要处理" : repairing ? "自修复中" : healing.phase === "PASSED" && runtime.active ? "已恢复运行" : runtime.active ? "健康运行" : "等待启动";
else if (phaseIndex === index && healing.phase !== "IDLE") state = failed ? "failed" : "active"; logState.dataset.state = failed ? "fault" : repairing ? "repairing" : runtime.active ? "healthy" : "idle";
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`;
} }
function renderCases() { function renderCases() {
@@ -290,26 +406,27 @@
if (latest.batch?.active) return; if (latest.batch?.active) return;
selectedCaseId = button.dataset.repairCase; selectedCaseId = button.dataset.repairCase;
renderCases(); renderCases();
renderTimeline();
renderTwin(); renderTwin();
}); });
}); });
const batch = latest.batch || {}; const selected = scenarioForDisplay();
const completed = Number(batch.completed || 0); const runtime = runtimeState();
const total = Number(batch.total || 6); $("#repairCaseCount").textContent = `${(latest.scenarios || []).length} 个用例`;
$("#repairBatchProgress").style.width = `${Math.min(100, (completed / Math.max(1, total)) * 100)}%`; $("#repairSelectedCase").textContent = selected
$("#repairBatchCount").textContent = `${completed} / ${total}`; ? `当前选择:${selected.short_title}` : "当前选择:—";
$("#repairBatchSummary").textContent = batch.active $("#repairInjectionCount").textContent = Number(runtime.injected_count || 0)
? `正在验证 ${batch.current_case_id || "下一项"} · 通过 ${batch.passed} · 失败 ${batch.failed}` ? `本次已注入 ${runtime.injected_count} 次 · 已修复 ${runtime.repaired_count || 0} 次`
: completed : "本次尚未注入故障";
? `批量验证完成 · 通过 ${batch.passed} · 失败 ${batch.failed}` 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() { function renderTwin() {
const healing = latest.healing; const healing = latest.healing;
const scenario = scenarioForDisplay(); const scenario = scenarioForDisplay();
if (!scenario) return; if (!scenario) return;
const runtime = runtimeState();
const telemetry = healing.telemetry || {}; const telemetry = healing.telemetry || {};
const live = latest.bench_twin || {}; const live = latest.bench_twin || {};
const bench = executionMode === "bench"; const bench = executionMode === "bench";
@@ -337,11 +454,19 @@
? (live.online ? "实物 CAN 同步" : "实物 CAN 暂离线") ? (live.online ? "实物 CAN 同步" : "实物 CAN 暂离线")
: "影子动作仿真"; : "影子动作仿真";
$("#repairTwinTask").textContent = healing.phase === "IDLE" $("#repairTwinTask").textContent = healing.phase === "IDLE"
? `等待启动:${scenario.normal_task}` : scenario.normal_task; ? (runtime.active ? runtime.motion_profile : "等待启动座椅运行")
$("#repairTwinStage").textContent = stage || "待机"; : scenario.normal_task;
$("#repairTwinState").textContent = healing.phase_text; $("#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 $("#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) => { $("#repairAxisStrip").innerHTML = axes.map((axis) => {
const position = Math.max(0, Math.min(100, Number(axis.position_pct || 0))); 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>`; 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 healing = latest.healing || {};
const hardwarePhase = latest.hardware_session?.progress?.phase; const hardwarePhase = latest.hardware_session?.progress?.phase;
const integration = latest.integration || {}; const integration = latest.integration || {};
const runtime = runtimeState();
$("#repairCanStatus").textContent = bridge.online ? "在线" : "离线"; $("#repairCanStatus").textContent = bridge.online ? "在线" : "离线";
$("#repairCanDot").dataset.state = bridge.online ? "online" : manager.state === "STARTING" ? "working" : "offline"; $("#repairCanDot").dataset.state = bridge.online ? "online" : manager.state === "STARTING" ? "working" : "offline";
$("#repairProtocolStatus").textContent = bridge.protocol_online ? "协议在线" : "未检测"; $("#repairProtocolStatus").textContent = bridge.protocol_online ? "协议在线" : "未检测";
@@ -453,9 +579,13 @@
}[hardwarePhase]; }[hardwarePhase];
const aiState = activeView === "hardware" && hardwareState const aiState = activeView === "hardware" && hardwareState
? hardwareState ? hardwareState
: [healing.phase_text || "等待验证", healing.phase === "PASSED" : runtime.active && healing.phase === "IDLE"
? "online" ? ["持续监控", "online"]
: healing.phase === "IDLE" ? "offline" : "working"]; : runtime.active && healing.phase === "PASSED"
? ["已恢复监控", "online"]
: [healing.phase_text || "等待验证", healing.phase === "PASSED"
? "online"
: healing.phase === "IDLE" ? "offline" : "working"];
$("#repairAiStatus").textContent = aiState[0]; $("#repairAiStatus").textContent = aiState[0];
$("#repairAiDot").dataset.state = aiState[1]; $("#repairAiDot").dataset.state = aiState[1];
$("#repairConnectionText").textContent = bridge.online ? "CAN 桥接在线" : manager.message || "CAN 桥接离线"; $("#repairConnectionText").textContent = bridge.online ? "CAN 桥接在线" : manager.message || "CAN 桥接离线";
@@ -472,6 +602,14 @@
? "真实台架动作受本机 admin、安全确认与桥接状态约束" ? "真实台架动作受本机 admin、安全确认与桥接状态约束"
: "影子仿真不会写入 ECU 或驱动实体设备"; : "影子仿真不会写入 ECU 或驱动实体设备";
$("#repairFirmwareFlash").disabled = !integration.firmware_tools_configured; $("#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")); window.CognitiveAgentConsoleV2?.enhanceSelect($("#repairExecutionMode"));
} }
@@ -484,7 +622,7 @@
function renderAll() { function renderAll() {
renderGlobal(); renderGlobal();
renderCases(); renderCases();
renderTimeline(); renderEventStream();
renderTwin(); renderTwin();
renderHardware(); renderHardware();
renderRecords(); renderRecords();
@@ -550,20 +688,33 @@
sessionStorage.setItem(MODE_KEY, executionMode); sessionStorage.setItem(MODE_KEY, executionMode);
if (latest) renderAll(); if (latest) renderAll();
}); });
$("#repairStart").addEventListener("click", async () => { $("#repairRuntimeToggle").addEventListener("click", async () => {
if (executionMode === "bench" && !(await confirmAction("即将向实物 ECU 写入受控故障并驱动座椅恢复。请确认运动区域无人、无遮挡且急停可用。"))) return; const active = Boolean(runtimeState().active);
runAction(() => apiPost("/healing/start", { case_id: selectedCaseId, execution_mode: executionMode }), executionMode === "bench" ? "实物自修复用例已启动" : "影子故障已注入,开始自修复");
});
$("#repairRunAll").addEventListener("click", async () => {
if (executionMode === "bench" && !(await confirmAction( if (executionMode === "bench" && !(await confirmAction(
"将依次执行6个实物故障用例;每项开始前会自动停止输出并完成四轴归零。请确认运动区域无人、无遮挡且急停可用。", active
"确认实物批量验证", ? "即将停止实物座椅连续运行。请确认可安全停止。"
: "即将启动实物座椅四电机协同自由运动。请确认运动区域无人、无遮挡且急停可用。",
active ? "确认停止" : "确认启动",
))) return; ))) 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 () => { $("#repairReset").addEventListener("click", async () => {
if (executionMode === "bench" && !(await confirmAction("四轴将依次回到机械零位。请确认运动区域无人、无遮挡且急停可用。", "确认归零"))) return; runAction(() => apiPost("/runtime/reset-statistics"), "本次运行统计已重置");
runAction(() => apiPost("/healing/reset", { execution_mode: executionMode }), executionMode === "bench" ? "四轴归零请求已提交" : "自修复验证已重置");
}); });
$("#repairFirmwareFlash").addEventListener("click", async () => { $("#repairFirmwareFlash").addEventListener("click", async () => {
if (!(await confirmAction("即将构建并烧录台架测试固件。请确认座椅运动区域无人、急停可用且 CMSIS-DAP 已连接。", "确认构建/烧录"))) return; if (!(await confirmAction("即将构建并烧录台架测试固件。请确认座椅运动区域无人、急停可用且 CMSIS-DAP 已连接。", "确认构建/烧录"))) return;
@@ -594,6 +745,12 @@
}); });
$("#repairSoftwareRecords").addEventListener("click", () => $("#repairSoftwareDialog").showModal()); $("#repairSoftwareRecords").addEventListener("click", () => $("#repairSoftwareDialog").showModal());
$("#repairHardwareRecords").addEventListener("click", () => $("#repairHardwareDialog").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())); $$('[data-repair-close]').forEach((button) => button.addEventListener("click", () => $(`#${button.dataset.repairClose}`).close()));
window.addEventListener("cognitive-agent:module-change", (event) => { window.addEventListener("cognitive-agent:module-change", (event) => {
if (event.detail?.module === "self-repair") poll(); if (event.detail?.module === "self-repair") poll();
@@ -607,6 +764,7 @@
if (!buildShell()) return; if (!buildShell()) return;
bindEvents(); bindEvents();
showView(activeView); showView(activeView);
renderAll();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
if (isActive()) poll(); if (isActive()) poll();
window.setInterval(poll, 600); 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [ const APP_SHELL = [
@@ -6,19 +6,18 @@ const APP_SHELL = [
"/index.html", "/index.html",
"/styles.css?v=dual-seat-9", "/styles.css?v=dual-seat-9",
"/network.js?v=android-pad-1", "/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", "/gesture-control.js?v=android-pad-7",
"/pwa.js?v=android-pad-1", "/pwa.js?v=android-pad-1",
"/console-v2.css?v=desktop-refactor-87", "/console-v2.css?v=desktop-refactor-106",
"/console-v2.js?v=desktop-refactor-28", "/console-v2.js?v=desktop-refactor-34",
"/self-extension.css?v=extension-12", "/self-extension.css?v=extension-18",
"/self-extension.js?v=extension-18", "/self-extension.js?v=extension-20",
"/self-calibration.css?v=calibration-6", "/self-calibration.css?v=calibration-10",
"/self-calibration.js?v=calibration-3", "/self-calibration.js?v=calibration-6",
"/self-repair.css?v=repair-17", "/self-repair.css?v=repair-21",
"/self-repair.js?v=repair-17", "/self-repair.js?v=repair-20",
"/self-repair-seat3d.js?v=repair-17", "/self-repair-seat3d.js?v=repair-18",
"/assets/self-calibration-model.bundle.js",
"/assets/models/n7_seat.glb", "/assets/models/n7_seat.glb",
"/manifest.webmanifest", "/manifest.webmanifest",
"/icons/app-icon-192.png", "/icons/app-icon-192.png",
@@ -260,6 +260,7 @@ class ConsoleState(object):
self.latest_report_path = None self.latest_report_path = None
self.batch = self._new_batch_state() self.batch = self._new_batch_state()
self.batch_thread = None self.batch_thread = None
self.runtime = self._new_runtime_state()
self.hardware_active = False self.hardware_active = False
self.hardware_run_id = None self.hardware_run_id = None
self.hardware_started_epoch = None self.hardware_started_epoch = None
@@ -535,6 +536,202 @@ class ConsoleState(object):
"finished_epoch": None, "results": [], "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 @staticmethod
def _bridge_summary(status, now): def _bridge_summary(status, now):
heartbeat = None if not isinstance(status, dict) else status.get( heartbeat = None if not isinstance(status, dict) else status.get(
@@ -944,6 +1141,7 @@ class ConsoleState(object):
hardware = self._hardware_snapshot(status, now) hardware = self._hardware_snapshot(status, now)
bridge = self._bridge_summary(status, now) bridge = self._bridge_summary(status, now)
healing = self.engine.snapshot(now) healing = self.engine.snapshot(now)
runtime = self._runtime_snapshot(healing, now)
self._save_completed_report(healing, hardware, bridge) self._save_completed_report(healing, hardware, bridge)
return { return {
"schema": "seat-self-healing-console-state-v1", "schema": "seat-self-healing-console-state-v1",
@@ -959,6 +1157,7 @@ class ConsoleState(object):
"bench_twin": self._bench_twin_snapshot(status, now), "bench_twin": self._bench_twin_snapshot(status, now),
"bridge_manager": self.bridge_manager.snapshot(), "bridge_manager": self.bridge_manager.snapshot(),
"healing": healing, "healing": healing,
"runtime": runtime,
"bench_reset": dict(self.bench_reset), "bench_reset": dict(self.bench_reset),
"hardware": hardware, "hardware": hardware,
"batch": dict(self.batch), "batch": dict(self.batch),
@@ -987,6 +1186,8 @@ class ConsoleState(object):
def start(self, case_id, execution_mode="shadow", allow_batch=False): def start(self, case_id, execution_mode="shadow", allow_batch=False):
with self.lock: with self.lock:
if not self.runtime.get("active") and not allow_batch:
raise ValueError("请先启动座椅连续运行,再注入故障")
if execution_mode == "bench": if execution_mode == "bench":
if (self.hardware_active or if (self.hardware_active or
(self.batch.get("active") and not allow_batch)): (self.batch.get("active") and not allow_batch)):
@@ -1006,10 +1207,15 @@ class ConsoleState(object):
raise ValueError("未识别到本工程座椅ECU协议,不能执行台架故障注入") raise ValueError("未识别到本工程座椅ECU协议,不能执行台架故障注入")
self._sync_bench_reset(status, now) self._sync_bench_reset(status, now)
zero = self._bench_zero_snapshot(status) zero = self._bench_zero_snapshot(status)
if (self.bench_reset.get("phase") != "PASSED" or system = (status or {}).get("system") or {}
not zero.get("all_zero")): 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( raise ValueError(
"请先点击‘重置并四轴归零’,确认四轴均为零位后再启动该用例") "连续运行状态无可信四轴位置,请先停止运行并完成四轴归零")
request_id = "SELF_HEAL_BENCH_%d" % int(now * 1000) request_id = "SELF_HEAL_BENCH_%d" % int(now * 1000)
request = { request = {
"schema": "seat-function-request-v1", "schema": "seat-function-request-v1",
@@ -1026,11 +1232,12 @@ class ConsoleState(object):
case_id, now=now, execution_mode="bench") case_id, now=now, execution_mode="bench")
self.engine.telemetry["bench_request_id"] = request_id self.engine.telemetry["bench_request_id"] = request_id
_atomic_json(PENDING_DIR / (request_id + ".json"), request) _atomic_json(PENDING_DIR / (request_id + ".json"), request)
self.bench_reset.update({ if not runtime_ready:
"phase": "CONSUMED", self.bench_reset.update({
"summary": "本次归零许可已用于%s;下一个用例前须重新归零" % "phase": "CONSUMED",
case_id, "summary": "本次归零许可已用于%s;下一个用例前须重新归零" %
}) case_id,
})
return value return value
self.latest_report_path = None self.latest_report_path = None
self.bench_request_id = None self.bench_request_id = None
@@ -1317,6 +1524,21 @@ class ConsoleState(object):
return value return value
def emergency_stop(self): 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) request_id = "SELF_HEAL_STOP_%d" % int(time.time() * 1000)
value = { value = {
"schema": "seat-function-emergency-v1", "schema": "seat-function-emergency-v1",
@@ -1591,6 +1813,14 @@ class SelfRepairService(object):
return self.state.start( return self.state.start(
payload.get("case_id"), payload.get("execution_mode", "shadow")) 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): def start_batch(self, payload):
return self.state.start_batch(payload.get("execution_mode", "shadow")) return self.state.start_batch(payload.get("execution_mode", "shadow"))
+10
View File
@@ -573,6 +573,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
path in physical_paths path in physical_paths
or ( or (
path in { path in {
"/api/self-repair/runtime",
"/api/self-repair/healing/start", "/api/self-repair/healing/start",
"/api/self-repair/healing/reset", "/api/self-repair/healing/reset",
} }
@@ -584,6 +585,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path == "/api/self-repair/healing/start": if path == "/api/self-repair/healing/start":
self._json({"ok": True, "healing": self.repair.start_healing(payload)}) self._json({"ok": True, "healing": self.repair.start_healing(payload)})
return 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": if path == "/api/self-repair/healing/run-all":
self._json({"ok": True, "batch": self.repair.start_batch(payload)}) self._json({"ok": True, "batch": self.repair.start_batch(payload)})
return return
+118 -16
View File
@@ -177,28 +177,28 @@ operations:
def test_static_console_and_runtime_endpoint(self) -> None: def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response: with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8") 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") app_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-7", timeout=3 f"{self.base_url}/gesture-control.js?v=android-pad-7", timeout=3
) as response: ) as response:
gesture_script = response.read().decode("utf-8") gesture_script = response.read().decode("utf-8")
with self.opener.open( 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: ) as response:
console_script = response.read().decode("utf-8") console_script = response.read().decode("utf-8")
with self.opener.open( 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: ) as response:
console_styles = response.read().decode("utf-8") console_styles = response.read().decode("utf-8")
with self.opener.open( 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: ) as response:
extension_script = response.read().decode("utf-8") extension_script = response.read().decode("utf-8")
with self.opener.open( 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: ) as response:
extension_styles = response.read().decode("utf-8") extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open( with self.opener.open(
f"{self.base_url}/api/extension/health", timeout=3 f"{self.base_url}/api/extension/health", timeout=3
) as response: ) as response:
@@ -219,22 +219,39 @@ operations:
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html) self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html) self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", 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('href="/styles.css?v=dual-seat-9"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-7"', html) self.assertIn('src="/gesture-control.js?v=android-pad-7"', html)
self.assertNotIn('class="vehicle-system-hud"', html) self.assertNotIn('class="vehicle-system-hud"', html)
self.assertIn('src="/console-v2.js?v=desktop-refactor-28"', html) self.assertIn('src="/console-v2.js?v=desktop-refactor-34"', html)
self.assertIn('href="/console-v2.css?v=desktop-refactor-87"', html) self.assertIn('href="/console-v2.css?v=desktop-refactor-106"', html)
self.assertIn('src="/self-extension.js?v=extension-18"', html) self.assertIn('src="/self-extension.js?v=extension-20"', html)
self.assertIn('href="/self-extension.css?v=extension-12"', html) self.assertIn('href="/self-extension.css?v=extension-18"', html)
self.assertNotIn("滚动查看更多", html) self.assertNotIn("滚动查看更多", html)
self.assertIn('aria-label="执行车舱控制指令"', html) self.assertIn('aria-label="执行车舱控制指令"', html)
self.assertIn('sessionStorage.getItem("cognitive-agent.demo-role")', console_script) self.assertIn('sessionStorage.getItem("cognitive-agent.demo-role")', console_script)
self.assertIn('id="v2SidebarToggle"', console_script) self.assertIn('id="v2SidebarToggle"', console_script)
self.assertIn('id = "v2ModuleSwitcher"', 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('label: "自编排", available: true', console_script)
self.assertIn('id: "self-calibration"', console_script) self.assertIn('id: "self-calibration"', console_script)
self.assertIn('label: "自标定"', 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("adminOnly: true", console_script)
self.assertIn( self.assertIn(
"$('.v2-module-tab[data-module=\"self-calibration\"]')", "$('.v2-module-tab[data-module=\"self-calibration\"]')",
@@ -245,6 +262,16 @@ operations:
self.assertIn('<span>Hi,</span><strong>user</strong>', console_script) self.assertIn('<span>Hi,</span><strong>user</strong>', console_script)
self.assertIn('id: "self-repair"', console_script) self.assertIn('id: "self-repair"', console_script)
self.assertIn('label: "自修复"', 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.assertIn('roleSelect.disabled = ui.activeModule === "self-repair"', console_script)
self.assertNotIn('label: "自诊断"', console_script) self.assertNotIn('label: "自诊断"', console_script)
self.assertIn('<small>手势模型</small>', 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('grid-template-columns: clamp(245px, 18.5vw, 310px)', console_styles)
self.assertIn('.v2-control-switch {', console_styles) self.assertIn('.v2-control-switch {', console_styles)
self.assertIn('.v2-module-switcher {', 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('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('background: color-mix(in srgb, var(--v2-accent) 30%, var(--v2-accent-soft))', console_styles)
self.assertIn('width: clamp(48px, 45%, 72px)', 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('root.id = "v2SelfExtension"', extension_script)
self.assertIn('data-extension-mode="study"', extension_script) self.assertIn('data-extension-mode="study"', extension_script)
self.assertIn('data-extension-mode="control"', 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('id="extensionExecuteButton"', extension_script)
self.assertIn('data-lucide="play"', extension_script) self.assertIn('data-lucide="play"', extension_script)
self.assertNotIn('id="extensionControlHealth"', 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-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="true"]', extension_styles)
self.assertIn('.v2-extension-type-card[data-extension-connected="false"]', 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.assertIn('grid-template-columns: repeat(3, minmax(0, 1fr))', extension_styles)
self.assertTrue(extension_health["success"]) self.assertTrue(extension_health["success"])
self.assertIn(extension_health["transport"], {"tcp", "mqtt", "simulation"}) self.assertIn(extension_health["transport"], {"tcp", "mqtt", "simulation"})
@@ -444,6 +507,45 @@ operations:
self.assertIn('id="vehicleSpeed"', html) self.assertIn('id="vehicleSpeed"', html)
self.assertNotIn('data-domain-panel="control_link"', html) self.assertNotIn('data-domain-panel="control_link"', html)
self.assertIn('id="cabinStateTitle">整车设备状态</h3>', 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="roof-visual"', html)
self.assertNotIn('class="ambient-visual"', html) self.assertNotIn('class="ambient-visual"', html)
self.assertIn('id="driverAirDirection"', html) self.assertIn('id="driverAirDirection"', html)
@@ -536,11 +638,11 @@ operations:
def test_self_extension_vehicle_success_contract_is_strict(self) -> None: def test_self_extension_vehicle_success_contract_is_strict(self) -> None:
with self.opener.open( 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: ) as response:
extension_script = response.read().decode("utf-8") extension_script = response.read().decode("utf-8")
with self.opener.open( 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: ) as response:
app_script = response.read().decode("utf-8") 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: with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8") html = response.read().decode("utf-8")
with urllib.request.urlopen( 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, timeout=3,
) as response: ) as response:
script = response.read().decode("utf-8") script = response.read().decode("utf-8")
with urllib.request.urlopen( 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, timeout=3,
) as response: ) as response:
styles = response.read().decode("utf-8") styles = response.read().decode("utf-8")
with urllib.request.urlopen( 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, timeout=3,
) as response: ) as response:
console_script = response.read().decode("utf-8") console_script = response.read().decode("utf-8")
@@ -148,24 +148,30 @@ class TestSelfCalibrationApi:
content_type = response.headers["Content-Type"] content_type = response.headers["Content-Type"]
content_security_policy = response.headers["Content-Security-Policy"] content_security_policy = response.headers["Content-Security-Policy"]
assert 'href="/self-calibration.css?v=calibration-6"' in html assert 'href="/self-calibration.css?v=calibration-10"' in html
assert 'src="/self-calibration.js?v=calibration-3"' in html assert 'src="/self-calibration.js?v=calibration-6"' in html
assert 'data-calibration-page="live"' in script assert 'data-calibration-page="live"' in script
assert 'data-calibration-view="live"' in script assert 'data-calibration-view="live"' in script
assert 'data-calibration-page="hall"' not in script assert 'data-calibration-page="hall"' not in script
assert 'data-calibration-page="stereo"' 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 "四轴实时控制" in script assert "四轴实时控制" in script
assert "ZED 实时画面" 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 "标定仿真台架" in script
assert "X-Cognitive-Agent-Role" in script assert "X-Cognitive-Agent-Role" in script
assert "--v2-sidebar: #dae3f5" in styles assert "--v2-sidebar: #dae3f5" in styles
assert "--v2-sidebar-deep: #b6c7ea" in styles assert "--v2-sidebar-deep: #b6c7ea" in styles
assert "--v2-accent-strong: #1e386b" in styles assert "--v2-accent-strong: #1e386b" in styles
assert '["self-calibration", "self-repair"].includes(module)' in console_script 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 len(model) == 845_992
assert content_type.startswith("model/gltf-binary") assert content_type.startswith("model/gltf-binary")
assert "img-src 'self' data: blob:;" in content_security_policy 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 len(state["scenarios"]) == 6
assert state["hardware"]["connector_enabled_count"] == 21 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( with urllib.request.urlopen(
self.request( self.request(
"/api/self-repair/healing/start", "/api/self-repair/healing/start",
@@ -135,26 +146,37 @@ class TestSelfRepairApi:
with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response: with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8") html = response.read().decode("utf-8")
with urllib.request.urlopen( 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: ) as response:
script = response.read().decode("utf-8") script = response.read().decode("utf-8").replace("\r\n", "\n")
with urllib.request.urlopen( 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: ) as response:
styles = response.read().decode("utf-8") styles = response.read().decode("utf-8")
with urllib.request.urlopen( 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: ) as response:
seat3d = response.read().decode("utf-8") seat3d = response.read().decode("utf-8")
with urllib.request.urlopen( 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: ) as response:
console_script = response.read().decode("utf-8") console_script = response.read().decode("utf-8")
assert 'href="/self-repair.css?v=repair-17"' in html assert 'href="/self-repair.css?v=repair-21"' in html
assert 'src="/self-repair.js?v=repair-17"' in html assert 'src="/self-repair.js?v=repair-20"' in html
assert 'src="/self-repair-seat3d.js?v=repair-17"' in html assert 'class="v2-module-heading"' in script
assert "量产故障用例" 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 'id="repairConfirmDialog"' in script
assert "confirmAction(" in script assert "confirmAction(" in script
assert "window.confirm" not in script assert "window.confirm" not in script
@@ -163,10 +185,15 @@ class TestSelfRepairApi:
assert "connectorRows" in script assert "connectorRows" in script
assert "Polling refreshes data only" in script assert "Polling refreshes data only" in script
assert "renderRecords();\n showView(activeView);" not 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 "--repair-deep-blue" in styles
assert '.console-v2[data-module="self-repair"] .section-kicker' in styles assert '.console-v2[data-module="self-repair"] .section-kicker' in styles
assert 'grid-template-areas: "twin cases timeline"' in styles assert 'grid-template-areas: "twin cases logs"' in styles
assert "background: color-mix(in srgb, var(--v2-accent-soft) 82%, white)" 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 ".v2-repair-toolbar button.primary:hover:not(:disabled)" in styles
assert "color: #fff;" in styles assert "color: #fff;" in styles
assert "seat-healing-state" in seat3d assert "seat-healing-state" in seat3d
@@ -175,7 +202,7 @@ class TestSelfRepairApi:
assert 'cssThemeColor("--v2-accent", "#315eaa")' in seat3d assert 'cssThemeColor("--v2-accent", "#315eaa")' in seat3d
assert 'viewer.addEventListener("pointerdown"' in seat3d assert 'viewer.addEventListener("pointerdown"' in seat3d
assert 'window.addEventListener("pointermove"' 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 '["self-calibration", "self-repair"].includes(module)' in console_script
assert 'roleSelect.disabled = ui.activeModule === "self-repair"' in console_script assert 'roleSelect.disabled = ui.activeModule === "self-repair"' in console_script
@@ -47,6 +47,49 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
self.extension_source, 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: def test_lan_http_origin_does_not_require_crypto_random_uuid(self) -> None:
id_source = function_source( id_source = function_source(
self.app_source, self.app_source,
+26
View File
@@ -4,6 +4,8 @@ import json
import time import time
from pathlib import Path from pathlib import Path
import pytest
from cognitive_agent.application.self_repair.healing_model import ( from cognitive_agent.application.self_repair.healing_model import (
PHASE_DIAGNOSED, PHASE_DIAGNOSED,
PHASE_INJECTED, PHASE_INJECTED,
@@ -75,6 +77,30 @@ def test_service_is_safe_without_machine_specific_tool_paths() -> None:
assert service.run_self_test() is True 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: def test_bridge_manager_does_not_arm_visible_tooling_by_default(tmp_path: Path) -> None:
manager = ZcanBridgeManager( manager = ZcanBridgeManager(
tmp_path / "bridge.json", tmp_path / "bridge.json",