feat: integrate production cockpit AI assistant
This commit is contained in:
+337
-20
@@ -4779,10 +4779,17 @@ function modeSecondsLabel(durationMs) {
|
||||
}
|
||||
|
||||
const MODE_GALLERY_GROUPS = [
|
||||
{
|
||||
id: "custom",
|
||||
title: "自定义模式",
|
||||
description: "由用户创建或调整,可继续编辑与删除",
|
||||
icon: "wand-sparkles",
|
||||
preferredIds: [],
|
||||
},
|
||||
{
|
||||
id: "static",
|
||||
title: "静态模式",
|
||||
description: "一次执行并保持目标状态,包含预设与自定义模式",
|
||||
description: "系统预设,一次执行并保持目标状态",
|
||||
icon: "layout-grid",
|
||||
preferredIds: [
|
||||
"zero_gravity",
|
||||
@@ -4820,6 +4827,126 @@ const MODE_DOMAIN_LABELS = {
|
||||
vehicle_status: "车辆状态",
|
||||
};
|
||||
|
||||
const MODE_TARGET_LABELS = {
|
||||
driver: "主驾",
|
||||
passenger: "副驾",
|
||||
front_left: "左前",
|
||||
front_right: "右前 / 副驾",
|
||||
rear_left: "左后",
|
||||
rear_right: "右后",
|
||||
front_row: "前排",
|
||||
rear_row: "后排",
|
||||
vehicle: "全车",
|
||||
cabin: "全舱",
|
||||
all: "全部",
|
||||
};
|
||||
|
||||
const MODE_TIMELINE_TARGET_LABELS = {
|
||||
longitudinal: "前后位置",
|
||||
cushion_tilt: "坐垫角度",
|
||||
backrest_recline: "靠背角度",
|
||||
legrest_height: "腿托高度",
|
||||
legrest_extension: "腿托伸缩",
|
||||
};
|
||||
|
||||
function modeOperationParameters(operation) {
|
||||
const parameters = operation?.parameters;
|
||||
if (Array.isArray(parameters)) {
|
||||
return parameters
|
||||
.filter((item) => item && typeof item === "object" && item.name)
|
||||
.map((item) => [String(item.name), item.value]);
|
||||
}
|
||||
if (!parameters || typeof parameters !== "object") return [];
|
||||
return Object.entries(parameters);
|
||||
}
|
||||
|
||||
function formatModeOperationValue(name, value) {
|
||||
const valueLabels = {
|
||||
on: "开启",
|
||||
off: "关闭",
|
||||
open: "打开",
|
||||
close: "关闭",
|
||||
stop: "停止",
|
||||
internal: "内循环",
|
||||
external: "外循环",
|
||||
};
|
||||
if (typeof value === "string" && valueLabels[value]) return valueLabels[value];
|
||||
return formatConfigValue(name, value);
|
||||
}
|
||||
|
||||
function modeOperationView(operation) {
|
||||
const domain = String(operation?.domain || "");
|
||||
const capability = String(operation?.capability || operation?.operation || "执行动作");
|
||||
const scope = String(
|
||||
operation?.target_scope
|
||||
|| operation?.target?.instance
|
||||
|| operation?.target?.zone
|
||||
|| operation?.target
|
||||
|| "vehicle",
|
||||
);
|
||||
const parameters = modeOperationParameters(operation)
|
||||
.map(([name, value]) => {
|
||||
const label = CONFIG_PARAMETER_LABELS[name] || name;
|
||||
return `${label}:${formatModeOperationValue(name, value)}`;
|
||||
})
|
||||
.join(",");
|
||||
return {
|
||||
device: MODE_DOMAIN_LABELS[domain] || domain || "车端设备",
|
||||
target: MODE_TARGET_LABELS[scope] || scope || "全车",
|
||||
action: CONFIG_CAPABILITY_LABELS[capability] || capability,
|
||||
parameters: parameters || "无附加参数",
|
||||
};
|
||||
}
|
||||
|
||||
function modeTimelineTargetSummary(targets) {
|
||||
if (!targets || typeof targets !== "object") return "按模式目标运行";
|
||||
return Object.entries(targets).map(([motor, target]) => {
|
||||
const label = MODE_TIMELINE_TARGET_LABELS[motor] || motor;
|
||||
if (!target || typeof target !== "object") return `${label}:${target}`;
|
||||
if (target.position_pct != null) return `${label}:${target.position_pct}%`;
|
||||
if (target.angle_deg != null) return `${label}:${target.angle_deg}°`;
|
||||
if (target.hall_pulse != null) return `${label}:${target.hall_pulse} Hall`;
|
||||
return `${label}:${Object.values(target).join(" / ")}`;
|
||||
}).join(",");
|
||||
}
|
||||
|
||||
function modeTimelineStepView(step, action) {
|
||||
const timeout = Number(step?.timeout_ms || 0);
|
||||
const duration = Number(step?.duration_ms || 0);
|
||||
const timing = duration > 0
|
||||
? `持续 ${modeSecondsLabel(duration)}s`
|
||||
: timeout > 0
|
||||
? `目标到位,保护上限 ${modeSecondsLabel(timeout)}s`
|
||||
: "目标到位";
|
||||
return {
|
||||
device: "座椅",
|
||||
target: "副驾",
|
||||
action,
|
||||
parameters: `${modeTimelineTargetSummary(step?.targets)};${timing}`,
|
||||
};
|
||||
}
|
||||
|
||||
function modeExecutionActionViews(mode) {
|
||||
const operations = Array.isArray(mode?.operations)
|
||||
? mode.operations.map(modeOperationView)
|
||||
: [];
|
||||
const timeline = mode?.timeline;
|
||||
if (!timeline || typeof timeline !== "object") return operations;
|
||||
const timelineSteps = [];
|
||||
if (timeline.entry?.targets) {
|
||||
timelineSteps.push(modeTimelineStepView(timeline.entry, "进入动态模式初始位置"));
|
||||
}
|
||||
if (Array.isArray(timeline.phases)) {
|
||||
timeline.phases.forEach((phase, index) => {
|
||||
timelineSteps.push(modeTimelineStepView(
|
||||
phase,
|
||||
`循环阶段 ${index + 1}:${phase?.name || phase?.id || "动态调节"}`,
|
||||
));
|
||||
});
|
||||
}
|
||||
return [...operations, ...timelineSteps];
|
||||
}
|
||||
|
||||
function isUserModeProfile(mode) {
|
||||
return mode?.origin === "user" || mode?.deletable === true;
|
||||
}
|
||||
@@ -4853,9 +4980,11 @@ function modeGalleryBuckets() {
|
||||
...definition,
|
||||
modes: orderModeGalleryProfiles(
|
||||
modes.filter((mode) => (
|
||||
definition.id === "dynamic"
|
||||
? isDynamicModeProfile(mode)
|
||||
: !isDynamicModeProfile(mode)
|
||||
definition.id === "custom"
|
||||
? isUserModeProfile(mode)
|
||||
: definition.id === "dynamic"
|
||||
? !isUserModeProfile(mode) && isDynamicModeProfile(mode)
|
||||
: !isUserModeProfile(mode) && !isDynamicModeProfile(mode)
|
||||
)),
|
||||
definition.preferredIds,
|
||||
),
|
||||
@@ -4978,14 +5107,20 @@ function createModeGalleryEmpty(group) {
|
||||
empty.dataset.modeGroupEmpty = group.id;
|
||||
empty.setAttribute("role", "status");
|
||||
const icon = document.createElement("i");
|
||||
icon.dataset.lucide = group.id === "dynamic" ? "activity" : "layout-grid";
|
||||
icon.dataset.lucide = group.id === "custom"
|
||||
? "wand-sparkles"
|
||||
: group.id === "dynamic"
|
||||
? "activity"
|
||||
: "layout-grid";
|
||||
const copy = document.createElement("span");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = `暂无${group.title}`;
|
||||
const description = document.createElement("small");
|
||||
description.textContent = group.id === "dynamic"
|
||||
? "支持持续变化的模式会显示在这里"
|
||||
: "可通过 AI助手创建新的静态模式";
|
||||
description.textContent = group.id === "custom"
|
||||
? "可通过 AI助手创建新的功能模式"
|
||||
: group.id === "dynamic"
|
||||
? "支持持续变化的系统模式会显示在这里"
|
||||
: "系统静态预设会显示在这里";
|
||||
copy.append(title, description);
|
||||
empty.append(icon, copy);
|
||||
return empty;
|
||||
@@ -5020,7 +5155,7 @@ function populateModeGallery() {
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "mode-gallery-grid";
|
||||
if (group.id === "static") grid.append(createModeGalleryAddCard());
|
||||
if (group.id === "custom") grid.append(createModeGalleryAddCard());
|
||||
group.modes.forEach((mode) => grid.append(createModeGalleryCard(mode)));
|
||||
if (!group.modes.length) grid.append(createModeGalleryEmpty(group));
|
||||
section.append(grid);
|
||||
@@ -5042,6 +5177,7 @@ function setModeRailExpanded(expanded) {
|
||||
toggle.title = open ? "收起功能模式" : "展开功能模式";
|
||||
content.hidden = !open;
|
||||
$(".mode-rail")?.classList.toggle("is-collapsed", !open);
|
||||
if (!open) hideModeActionPopover({ immediate: true });
|
||||
const icon = $("i", toggle);
|
||||
if (icon) icon.dataset.lucide = open ? "chevron-up" : "chevron-down";
|
||||
refreshIcons();
|
||||
@@ -5816,6 +5952,168 @@ function installModeChipScroller() {
|
||||
window.requestAnimationFrame(updateScrollState);
|
||||
}
|
||||
|
||||
let modeActionPopoverHideTimer = null;
|
||||
let modeActionPopoverAnchor = null;
|
||||
|
||||
function ensureModeActionPopover() {
|
||||
let popover = $("#modeChipActionPopover");
|
||||
if (popover) return popover;
|
||||
popover = document.createElement("aside");
|
||||
popover.id = "modeChipActionPopover";
|
||||
popover.className = "mode-chip-action-popover";
|
||||
popover.setAttribute("role", "tooltip");
|
||||
popover.hidden = true;
|
||||
|
||||
const header = document.createElement("header");
|
||||
const headingCopy = document.createElement("span");
|
||||
const eyebrow = document.createElement("small");
|
||||
eyebrow.textContent = "执行动作预览";
|
||||
const title = document.createElement("strong");
|
||||
title.dataset.modeActionTitle = "true";
|
||||
headingCopy.append(eyebrow, title);
|
||||
const count = document.createElement("b");
|
||||
count.dataset.modeActionCount = "true";
|
||||
header.append(headingCopy, count);
|
||||
|
||||
const list = document.createElement("ol");
|
||||
list.dataset.modeActionList = "true";
|
||||
popover.append(header, list);
|
||||
document.body.append(popover);
|
||||
return popover;
|
||||
}
|
||||
|
||||
function renderModeActionPopover(mode) {
|
||||
const popover = ensureModeActionPopover();
|
||||
const actions = modeExecutionActionViews(mode);
|
||||
$("[data-mode-action-title]", popover).textContent = mode?.name || mode?.id || "功能模式";
|
||||
$("[data-mode-action-count]", popover).textContent = actions.length
|
||||
? `${actions.length} 步`
|
||||
: `${Number(mode?.operation_count || 0)} 项`;
|
||||
const list = $("[data-mode-action-list]", popover);
|
||||
list.replaceChildren();
|
||||
if (!actions.length) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "mode-chip-action-empty";
|
||||
empty.textContent = "执行动作明细暂未加载,请刷新后重试。";
|
||||
list.append(empty);
|
||||
return popover;
|
||||
}
|
||||
actions.forEach((action, index) => {
|
||||
const item = document.createElement("li");
|
||||
const step = document.createElement("span");
|
||||
step.className = "mode-chip-action-step";
|
||||
step.textContent = String(index + 1).padStart(2, "0");
|
||||
const copy = document.createElement("span");
|
||||
copy.className = "mode-chip-action-copy";
|
||||
const actionName = document.createElement("strong");
|
||||
actionName.textContent = action.action;
|
||||
const metadata = document.createElement("small");
|
||||
const device = document.createElement("span");
|
||||
device.innerHTML = "<b>设备</b>";
|
||||
device.append(document.createTextNode(action.device));
|
||||
const target = document.createElement("span");
|
||||
target.innerHTML = "<b>目标</b>";
|
||||
target.append(document.createTextNode(action.target));
|
||||
const parameters = document.createElement("span");
|
||||
parameters.innerHTML = "<b>参数</b>";
|
||||
parameters.append(document.createTextNode(action.parameters));
|
||||
metadata.append(device, target, parameters);
|
||||
copy.append(actionName, metadata);
|
||||
item.append(step, copy);
|
||||
list.append(item);
|
||||
});
|
||||
return popover;
|
||||
}
|
||||
|
||||
function positionModeActionPopover(anchor, popover) {
|
||||
const margin = 12;
|
||||
const gap = 8;
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const popoverRect = popover.getBoundingClientRect();
|
||||
const maximumLeft = Math.max(margin, window.innerWidth - popoverRect.width - margin);
|
||||
const left = Math.max(
|
||||
margin,
|
||||
Math.min(maximumLeft, anchorRect.left + (anchorRect.width - popoverRect.width) / 2),
|
||||
);
|
||||
const availableBelow = window.innerHeight - anchorRect.bottom - margin;
|
||||
const availableAbove = anchorRect.top - margin;
|
||||
const placeAbove = availableBelow < Math.min(popoverRect.height, 320)
|
||||
&& availableAbove > availableBelow;
|
||||
const desiredTop = placeAbove
|
||||
? anchorRect.top - popoverRect.height - gap
|
||||
: anchorRect.bottom + gap;
|
||||
const maximumTop = Math.max(margin, window.innerHeight - popoverRect.height - margin);
|
||||
popover.style.left = `${Math.max(margin, Math.min(maximumLeft, left))}px`;
|
||||
popover.style.top = `${Math.max(margin, Math.min(maximumTop, desiredTop))}px`;
|
||||
popover.classList.toggle("is-above", placeAbove);
|
||||
}
|
||||
|
||||
function showModeActionPopover(button) {
|
||||
const mode = modeProfiles().find((item) => item.id === button?.dataset.modeProfileId);
|
||||
if (!mode) return;
|
||||
window.clearTimeout(modeActionPopoverHideTimer);
|
||||
modeActionPopoverHideTimer = null;
|
||||
const popover = renderModeActionPopover(mode);
|
||||
modeActionPopoverAnchor = button;
|
||||
button.setAttribute("aria-describedby", popover.id);
|
||||
popover.hidden = false;
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!popover.hidden && modeActionPopoverAnchor === button) {
|
||||
positionModeActionPopover(button, popover);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function hideModeActionPopover({ immediate = false } = {}) {
|
||||
window.clearTimeout(modeActionPopoverHideTimer);
|
||||
const hide = () => {
|
||||
const popover = $("#modeChipActionPopover");
|
||||
if (popover) popover.hidden = true;
|
||||
modeActionPopoverAnchor = null;
|
||||
modeActionPopoverHideTimer = null;
|
||||
};
|
||||
if (immediate) hide();
|
||||
else modeActionPopoverHideTimer = window.setTimeout(hide, 90);
|
||||
}
|
||||
|
||||
function installModeActionPreview() {
|
||||
const group = $(".mode-chips");
|
||||
if (!group || group.dataset.modeActionPreviewBound === "true") return;
|
||||
group.dataset.modeActionPreviewBound = "true";
|
||||
const resolveButton = (event) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
return target?.closest("button[data-mode-profile-id]");
|
||||
};
|
||||
group.addEventListener("pointerover", (event) => {
|
||||
const button = resolveButton(event);
|
||||
if (button && !button.contains(event.relatedTarget)) showModeActionPopover(button);
|
||||
});
|
||||
group.addEventListener("pointerout", (event) => {
|
||||
const button = resolveButton(event);
|
||||
if (button && !button.contains(event.relatedTarget)) hideModeActionPopover();
|
||||
});
|
||||
group.addEventListener("focusin", (event) => {
|
||||
const button = resolveButton(event);
|
||||
if (button) showModeActionPopover(button);
|
||||
});
|
||||
group.addEventListener("focusout", (event) => {
|
||||
const button = resolveButton(event);
|
||||
if (button && !button.contains(event.relatedTarget)) hideModeActionPopover();
|
||||
});
|
||||
group.addEventListener("scroll", () => hideModeActionPopover({ immediate: true }), { passive: true });
|
||||
group.addEventListener("pointerdown", () => hideModeActionPopover({ immediate: true }));
|
||||
group.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") hideModeActionPopover({ immediate: true });
|
||||
});
|
||||
const popover = ensureModeActionPopover();
|
||||
popover.addEventListener("pointerenter", () => {
|
||||
window.clearTimeout(modeActionPopoverHideTimer);
|
||||
modeActionPopoverHideTimer = null;
|
||||
});
|
||||
popover.addEventListener("pointerleave", () => hideModeActionPopover());
|
||||
window.addEventListener("resize", () => hideModeActionPopover({ immediate: true }), { passive: true });
|
||||
}
|
||||
|
||||
function populateModeChips() {
|
||||
const group = $(".mode-chips");
|
||||
if (!group) return;
|
||||
@@ -5857,6 +6155,12 @@ function populateModeChips() {
|
||||
button.dataset.modeCommand = `进入${mode.name}`;
|
||||
button.dataset.modeOrigin = mode?.origin || "preset";
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
const actionCount = modeExecutionActionViews(mode).length
|
||||
|| Number(mode.operation_count || 0);
|
||||
button.setAttribute(
|
||||
"aria-label",
|
||||
`${mode.name || mode.id},${actionCount} 项执行动作,点击执行`,
|
||||
);
|
||||
let summary = $(".mode-chip-summary", button);
|
||||
if (!summary) {
|
||||
summary = document.createElement("small");
|
||||
@@ -5905,7 +6209,9 @@ function populateModeChips() {
|
||||
: `${phase.name} 目标到位`;
|
||||
}).join(" → ")
|
||||
: "";
|
||||
button.title = [mode.description || mode.name, phaseSummary].filter(Boolean).join("\n");
|
||||
button.title = [mode.description || mode.name, phaseSummary, "悬浮或聚焦查看全部执行动作"]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
const preferredStaticOrder = [
|
||||
@@ -5915,12 +6221,20 @@ function populateModeChips() {
|
||||
"queen_passenger_driving",
|
||||
"leisure_lounger",
|
||||
];
|
||||
const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"];
|
||||
const allButtons = buttons();
|
||||
const userButtons = allButtons.filter((button) => button.dataset.modeOrigin === "user");
|
||||
const dynamicButtons = dynamicModeOrder
|
||||
.map((modeId) => allButtons.find((button) => button.dataset.modeProfileId === modeId))
|
||||
.filter(Boolean);
|
||||
const profilesById = new Map(modes.map((mode) => [String(mode.id), mode]));
|
||||
const userButtons = allButtons.filter((button) => {
|
||||
const mode = profilesById.get(String(button.dataset.modeProfileId || ""));
|
||||
return mode ? isUserModeProfile(mode) : button.dataset.modeOrigin === "user";
|
||||
});
|
||||
const dynamicOrder = new Map([["rocking_horse", 0], ["dynamic_cradle", 1]]);
|
||||
const dynamicButtons = allButtons.filter((button) => {
|
||||
const mode = profilesById.get(String(button.dataset.modeProfileId || ""));
|
||||
return mode && !isUserModeProfile(mode) && isDynamicModeProfile(mode);
|
||||
}).sort((left, right) => (
|
||||
(dynamicOrder.get(left.dataset.modeProfileId) ?? Number.MAX_SAFE_INTEGER)
|
||||
- (dynamicOrder.get(right.dataset.modeProfileId) ?? Number.MAX_SAFE_INTEGER)
|
||||
));
|
||||
const staticButtons = allButtons.filter(
|
||||
(button) => !userButtons.includes(button) && !dynamicButtons.includes(button),
|
||||
);
|
||||
@@ -5946,15 +6260,17 @@ function populateModeChips() {
|
||||
group.append(divider);
|
||||
};
|
||||
if (userButtons.length) {
|
||||
appendGroupLabel("我的模式", "user");
|
||||
appendGroupLabel("自定义模式", "custom");
|
||||
userButtons.forEach((button) => group.append(button));
|
||||
}
|
||||
if (userButtons.length && staticButtons.length) appendDivider("user");
|
||||
if (staticButtons.length) appendGroupLabel("预设模式", "preset");
|
||||
if (userButtons.length && (staticButtons.length || dynamicButtons.length)) {
|
||||
appendDivider("custom");
|
||||
}
|
||||
if (staticButtons.length) appendGroupLabel("静态模式", "static");
|
||||
preferredStaticButtons.forEach((button) => group.append(button));
|
||||
otherStaticButtons.forEach((button) => group.append(button));
|
||||
if (dynamicButtons.length && staticButtons.length) {
|
||||
appendDivider("dynamic");
|
||||
if (dynamicButtons.length) {
|
||||
if (userButtons.length || staticButtons.length) appendDivider("dynamic");
|
||||
appendGroupLabel("动态模式", "dynamic");
|
||||
}
|
||||
dynamicButtons.forEach((button) => group.append(button));
|
||||
@@ -8423,6 +8739,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
selectVehicle(event.target.value);
|
||||
});
|
||||
installModeChipScroller();
|
||||
installModeActionPreview();
|
||||
$(".mode-chips").addEventListener("click", (event) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const button = target?.closest("button[data-mode-command]");
|
||||
|
||||
Reference in New Issue
Block a user