Merge origin/feature/N7Web into feature/N7Web
Integrate the remote firmware workbench, OTA delivery, and timed mode schedules while preserving the local N7 scene and runtime changes.
This commit is contained in:
+247
-21
@@ -5166,7 +5166,18 @@ function modeOperationParameters(operation) {
|
||||
return Object.entries(parameters);
|
||||
}
|
||||
|
||||
function formatModeOperationValue(name, value) {
|
||||
function formatModeOperationValue(name, value, capability = "") {
|
||||
if (name === "level" && ["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"].includes(capability)) {
|
||||
return { 1: "高档", 2: "低档" }[value] || formatConfigValue(name, value);
|
||||
}
|
||||
if (capability === "seat.set_driver_massage_native") {
|
||||
const labels = {
|
||||
action: { mode: "切换按摩模式", strength: "调节按摩力度", off: "关闭" },
|
||||
mode: { wave: "波浪", pulse: "脉冲", stretch: "舒展", slack: "舒缓", relax: "放松" },
|
||||
strength: { 1: "轻柔", 2: "中等", 3: "较强" },
|
||||
};
|
||||
if (labels[name]?.[value]) return labels[name][value];
|
||||
}
|
||||
const valueLabels = {
|
||||
on: "开启",
|
||||
off: "关闭",
|
||||
@@ -5193,7 +5204,7 @@ function modeOperationView(operation) {
|
||||
const parameters = modeOperationParameters(operation)
|
||||
.map(([name, value]) => {
|
||||
const label = CONFIG_PARAMETER_LABELS[name] || name;
|
||||
return `${label}:${formatModeOperationValue(name, value)}`;
|
||||
return `${label}:${formatModeOperationValue(name, value, capability)}`;
|
||||
})
|
||||
.join(",");
|
||||
return {
|
||||
@@ -5232,7 +5243,50 @@ function modeTimelineStepView(step, action) {
|
||||
};
|
||||
}
|
||||
|
||||
function modeScheduleDurationLabel(durationMs) {
|
||||
const minutes = Math.floor(durationMs / 60000);
|
||||
const seconds = Number(((durationMs % 60000) / 1000).toFixed(3));
|
||||
return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`;
|
||||
}
|
||||
|
||||
function modeScheduleView(mode) {
|
||||
const schedule = mode?.schedule;
|
||||
if (
|
||||
schedule?.schema !== "cognitive-agent-mode-schedule/v1"
|
||||
|| !Array.isArray(schedule.phases) || !schedule.phases.length
|
||||
|| !Number.isInteger(schedule.repeat_count)
|
||||
|| schedule.repeat_count < 1 || schedule.repeat_count > 20
|
||||
|| schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms)
|
||||
|| phase.duration_ms <= 0 || !Array.isArray(phase.operations))
|
||||
) return null;
|
||||
let offsetMs = 0;
|
||||
const phases = schedule.phases.map((phase, index) => {
|
||||
const view = {
|
||||
name: String(phase.name || `阶段 ${index + 1}`),
|
||||
timing: `+${modeScheduleDurationLabel(offsetMs)} 开始 · 持续 ${modeScheduleDurationLabel(phase.duration_ms)}`,
|
||||
actions: phase.operations.map(modeOperationView),
|
||||
};
|
||||
offsetMs += phase.duration_ms;
|
||||
return view;
|
||||
});
|
||||
return {
|
||||
phases,
|
||||
summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${modeScheduleDurationLabel(offsetMs * schedule.repeat_count)}`,
|
||||
stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚;时间为每轮的相对时间。",
|
||||
};
|
||||
}
|
||||
|
||||
function modeExecutionActionViews(mode) {
|
||||
const schedule = modeScheduleView(mode);
|
||||
if (schedule) {
|
||||
// Runtime operations may contain a flattened projection. Prefer phases so
|
||||
// repeated device targets remain distinct and are not listed twice.
|
||||
return schedule.phases.flatMap((phase, index) => phase.actions.map((action) => ({
|
||||
...action,
|
||||
phase: `阶段 ${index + 1}:${phase.name}`,
|
||||
timing: phase.timing,
|
||||
})));
|
||||
}
|
||||
const operations = Array.isArray(mode?.operations)
|
||||
? mode.operations.map(modeOperationView)
|
||||
: [];
|
||||
@@ -5253,6 +5307,57 @@ function modeExecutionActionViews(mode) {
|
||||
return [...operations, ...timelineSteps];
|
||||
}
|
||||
|
||||
function modeScheduleTrialMatches(draft, payload) {
|
||||
if (!draft?.schedule) return true;
|
||||
if (!modeScheduleView(draft) || !modeScheduleView(payload)) return false;
|
||||
const signature = (schedule) => JSON.stringify([
|
||||
schedule.schema, schedule.repeat_count,
|
||||
schedule.phases.map((phase) => [
|
||||
phase.id, phase.name, phase.duration_ms,
|
||||
phase.operations.map((operation) => [
|
||||
operation.domain, operation.capability,
|
||||
operation.target_scope || operation.target?.instance || operation.target?.zone,
|
||||
modeOperationParameters(operation).sort(([left], [right]) => left.localeCompare(right)),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
const execution = payload.schedule_execution;
|
||||
const totalDurationMs = draft.schedule.phases.reduce((total, phase) => total + phase.duration_ms, 0)
|
||||
* draft.schedule.repeat_count;
|
||||
return signature(draft.schedule) === signature(payload.schedule)
|
||||
&& execution?.status === "completed" && execution.completed === true
|
||||
&& execution.virtual_elapsed_ms === totalDurationMs;
|
||||
}
|
||||
|
||||
function modeScheduleExecutionView(result) {
|
||||
const execution = result?.schedule_execution;
|
||||
if (!execution || typeof execution !== "object") return null;
|
||||
const status = String(execution.status || "");
|
||||
if (status === "running" && result.status === "running") {
|
||||
return {
|
||||
state: "processing", badge: "已启动", title: "动态时间轴已启动",
|
||||
detail: "后续动作将按阶段执行;可输入“停止动态模式”提前结束。已执行的设备状态不会自动回滚。",
|
||||
};
|
||||
}
|
||||
if (["stopped", "cancelled"].includes(status)) {
|
||||
return {
|
||||
state: "succeeded", badge: "已停止", title: "动态时间轴已停止",
|
||||
detail: "后续阶段不再执行;已经执行的设备状态不会自动回滚。",
|
||||
};
|
||||
}
|
||||
if (status === "completed" && execution.completed === true) {
|
||||
const simulated = execution.virtual_elapsed_ms != null;
|
||||
return {
|
||||
state: "succeeded", badge: simulated ? "模拟通过" : "已结束",
|
||||
title: simulated ? "动态时间轴模拟试运行通过" : "动态时间轴已结束",
|
||||
detail: simulated
|
||||
? "已使用虚拟时钟校验全部阶段;未按真实时间等待,也未向车辆下发控制。"
|
||||
: "已处理全部计划阶段,请以车辆反馈确认各设备的实际状态。",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isUserModeProfile(mode) {
|
||||
return mode?.origin === "user" || mode?.deletable === true;
|
||||
}
|
||||
@@ -5264,6 +5369,7 @@ function isN7SyncedModeProfile(mode) {
|
||||
function isDynamicModeProfile(mode) {
|
||||
const behavior = String(mode?.behavior || "").trim().toLowerCase();
|
||||
if (["cyclic", "dynamic"].includes(behavior)) return true;
|
||||
if (modeScheduleView(mode)) return true;
|
||||
if (behavior === "static") return false;
|
||||
if (mode?.timeline && typeof mode.timeline === "object") return true;
|
||||
return LEGACY_DYNAMIC_MODE_IDS.has(String(mode?.id || ""));
|
||||
@@ -5348,6 +5454,34 @@ function createModeGalleryCard(mode) {
|
||||
button.append(iconBox, copy);
|
||||
shell.append(button);
|
||||
|
||||
const schedule = modeScheduleView(mode);
|
||||
if (schedule) {
|
||||
meta.textContent = schedule.summary;
|
||||
const details = document.createElement("details");
|
||||
details.className = "mode-gallery-schedule-details";
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = "查看分阶段执行动作";
|
||||
const list = document.createElement("ol");
|
||||
schedule.phases.forEach((phase) => {
|
||||
const item = document.createElement("li");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = phase.name;
|
||||
const timing = document.createElement("small");
|
||||
timing.textContent = phase.timing;
|
||||
item.append(title, timing);
|
||||
phase.actions.forEach((action) => {
|
||||
const row = document.createElement("p");
|
||||
row.textContent = `${action.target} · ${action.action}:${action.parameters}`;
|
||||
item.append(row);
|
||||
});
|
||||
list.append(item);
|
||||
});
|
||||
const hint = document.createElement("p");
|
||||
hint.textContent = schedule.stopHint;
|
||||
details.append(summary, list, hint);
|
||||
shell.append(details);
|
||||
}
|
||||
|
||||
const editButton = document.createElement("button");
|
||||
editButton.className = "mode-gallery-edit";
|
||||
editButton.type = "button";
|
||||
@@ -6313,25 +6447,37 @@ function installModeChipScroller() {
|
||||
pointerStartScroll = group.scrollLeft;
|
||||
dragged = false;
|
||||
pointerCaptured = false;
|
||||
suppressClick = false;
|
||||
});
|
||||
group.addEventListener("pointermove", (event) => {
|
||||
if (pointerId !== event.pointerId) return;
|
||||
if (event.buttons !== 1) {
|
||||
finishPointerDrag(event);
|
||||
return;
|
||||
}
|
||||
const distance = event.clientX - pointerStartX;
|
||||
if (!dragged && Math.abs(distance) < 5) return;
|
||||
dragged = true;
|
||||
if (!pointerCaptured && typeof group.setPointerCapture === "function") {
|
||||
group.setPointerCapture(pointerId);
|
||||
pointerCaptured = true;
|
||||
if (!dragged) {
|
||||
// Capturing on pointerdown retargets a normal button click to the rail.
|
||||
// Capture only after movement establishes that this gesture is a drag.
|
||||
dragged = true;
|
||||
if (typeof group.setPointerCapture === "function") {
|
||||
group.setPointerCapture(pointerId);
|
||||
pointerCaptured = true;
|
||||
}
|
||||
group.classList.add("is-dragging");
|
||||
}
|
||||
group.classList.add("is-dragging");
|
||||
group.scrollLeft = Math.max(0, Math.min(maximumScroll(), pointerStartScroll - distance));
|
||||
updateScrollState();
|
||||
event.preventDefault();
|
||||
});
|
||||
const finishPointerDrag = (event) => {
|
||||
if (pointerId !== event.pointerId) return;
|
||||
if (pointerCaptured) group.releasePointerCapture?.(pointerId);
|
||||
const finishedPointerId = pointerId;
|
||||
pointerId = null;
|
||||
if (pointerCaptured && group.hasPointerCapture?.(finishedPointerId)) {
|
||||
group.releasePointerCapture(finishedPointerId);
|
||||
}
|
||||
pointerCaptured = false;
|
||||
group.classList.remove("is-dragging");
|
||||
if (dragged) {
|
||||
@@ -6342,6 +6488,7 @@ function installModeChipScroller() {
|
||||
};
|
||||
group.addEventListener("pointerup", finishPointerDrag);
|
||||
group.addEventListener("pointercancel", finishPointerDrag);
|
||||
group.addEventListener("lostpointercapture", finishPointerDrag);
|
||||
group.addEventListener("click", (event) => {
|
||||
if (!suppressClick) return;
|
||||
event.preventDefault();
|
||||
@@ -6392,10 +6539,13 @@ function ensureModeActionPopover() {
|
||||
|
||||
function renderModeActionPopover(mode) {
|
||||
const popover = ensureModeActionPopover();
|
||||
const schedule = modeScheduleView(mode);
|
||||
const commandFlow = modeEditorReadableCommands(mode?.command_flow);
|
||||
const usesLatestCommandFlow = commandFlow.length > 0;
|
||||
const usesLatestCommandFlow = !schedule && commandFlow.length > 0;
|
||||
const actions = usesLatestCommandFlow ? commandFlow : modeExecutionActionViews(mode);
|
||||
$(`[data-mode-action-eyebrow]`, popover).textContent = usesLatestCommandFlow
|
||||
$(`[data-mode-action-eyebrow]`, popover).textContent = schedule
|
||||
? "动态时间轴"
|
||||
: usesLatestCommandFlow
|
||||
? "最新控制指令流"
|
||||
: "执行动作预览";
|
||||
$("[data-mode-action-title]", popover).textContent = mode?.name || mode?.id || "功能模式";
|
||||
@@ -6404,6 +6554,12 @@ function renderModeActionPopover(mode) {
|
||||
: `${Number(mode?.operation_count || 0)} 项`;
|
||||
const list = $("[data-mode-action-list]", popover);
|
||||
list.replaceChildren();
|
||||
if (schedule) {
|
||||
const summary = document.createElement("li");
|
||||
summary.className = "mode-schedule-popover-summary";
|
||||
summary.textContent = `${schedule.summary}。${schedule.stopHint}`;
|
||||
list.append(summary);
|
||||
}
|
||||
if (!actions.length) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "mode-chip-action-empty";
|
||||
@@ -6422,6 +6578,12 @@ function renderModeActionPopover(mode) {
|
||||
actionName.textContent = usesLatestCommandFlow
|
||||
? String(action.category || "控制指令")
|
||||
: action.action;
|
||||
if (action.phase) {
|
||||
const phase = document.createElement("small");
|
||||
phase.className = "mode-schedule-popover-phase";
|
||||
phase.textContent = `${action.phase} · ${action.timing}`;
|
||||
copy.append(phase);
|
||||
}
|
||||
const metadata = document.createElement("small");
|
||||
const appendMetadata = (label, value) => {
|
||||
const text = String(value || "").trim();
|
||||
@@ -6703,8 +6865,17 @@ function populateModeChips() {
|
||||
runIcon.dataset.modeRun = "true";
|
||||
runIcon.title = `执行${mode.name || mode.id}`;
|
||||
const timeline = mode.timeline;
|
||||
const schedule = modeScheduleView(mode);
|
||||
let cycleBadge = $(".mode-cycle-badge", button);
|
||||
if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) {
|
||||
if (schedule) {
|
||||
if (!cycleBadge) {
|
||||
cycleBadge = document.createElement("small");
|
||||
cycleBadge.className = "mode-cycle-badge";
|
||||
button.append(cycleBadge);
|
||||
}
|
||||
cycleBadge.textContent = schedule.summary;
|
||||
button.dataset.modeBehavior = "dynamic";
|
||||
} else if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) {
|
||||
if (!cycleBadge) {
|
||||
cycleBadge = document.createElement("small");
|
||||
cycleBadge.className = "mode-cycle-badge";
|
||||
@@ -6736,7 +6907,7 @@ function populateModeChips() {
|
||||
: `${phase.name} 目标到位`;
|
||||
}).join(" → ")
|
||||
: "";
|
||||
button.title = [mode.description || mode.name, phaseSummary, "悬浮或聚焦查看全部执行动作"]
|
||||
button.title = [mode.description || mode.name, schedule?.summary || phaseSummary, "悬浮或聚焦查看全部执行动作"]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
});
|
||||
@@ -6850,7 +7021,7 @@ function setModeExecutionState(modeProfileId, modeState = "idle") {
|
||||
$$(".mode-chips button[data-mode-profile-id], #modeGallery button[data-mode-profile-id]").forEach((button) => {
|
||||
const active = button.dataset.modeProfileId === modeProfileId && modeState !== "idle";
|
||||
button.classList.toggle("running", active && modeState === "running");
|
||||
button.classList.toggle("succeeded", active && modeState === "succeeded");
|
||||
button.classList.toggle("succeeded", active && ["succeeded", "started", "ended", "stopped"].includes(modeState));
|
||||
button.classList.toggle("failed", active && modeState === "failed");
|
||||
button.dataset.modeState = active ? modeState : "idle";
|
||||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
@@ -6859,6 +7030,12 @@ function setModeExecutionState(modeProfileId, modeState = "idle") {
|
||||
if (stateLabel) {
|
||||
stateLabel.textContent = active && modeState === "running"
|
||||
? "执行中"
|
||||
: active && modeState === "started"
|
||||
? "已启动"
|
||||
: active && modeState === "ended"
|
||||
? "已结束"
|
||||
: active && modeState === "stopped"
|
||||
? "已停止"
|
||||
: active && modeState === "succeeded"
|
||||
? "已完成"
|
||||
: active && modeState === "failed"
|
||||
@@ -6866,7 +7043,7 @@ function setModeExecutionState(modeProfileId, modeState = "idle") {
|
||||
: "点击执行";
|
||||
}
|
||||
});
|
||||
if (modeProfileId && ["succeeded", "failed"].includes(modeState)) {
|
||||
if (modeProfileId && ["succeeded", "failed", "started", "ended", "stopped"].includes(modeState)) {
|
||||
state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800);
|
||||
}
|
||||
}
|
||||
@@ -6937,11 +7114,14 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) {
|
||||
);
|
||||
if (!payload) throw new Error("模式卡片未返回执行结果");
|
||||
renderResult(payload);
|
||||
const scheduleRunning = modeScheduleExecutionView(payload)?.state === "processing";
|
||||
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
|
||||
$("#resultTitle").textContent = completed
|
||||
$("#resultTitle").textContent = scheduleRunning
|
||||
? `${mode.name}时间轴已启动`
|
||||
: completed
|
||||
? `${mode.name}执行完成`
|
||||
: `${mode.name}执行未完成`;
|
||||
setModeExecutionState(mode.id, completed ? "succeeded" : "failed");
|
||||
setModeExecutionState(mode.id, scheduleRunning ? "started" : completed ? "succeeded" : "failed");
|
||||
if (assistantModeHandle && assistant?.completeModeExecution) {
|
||||
assistant.completeModeExecution(assistantModeHandle, mode, payload, {
|
||||
command: modeCommand,
|
||||
@@ -6950,7 +7130,9 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) {
|
||||
assistant?.announceModeExecution?.(mode, payload, { command: modeCommand });
|
||||
}
|
||||
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
|
||||
showToast(completed
|
||||
showToast(scheduleRunning
|
||||
? "时间轴已启动,可输入“停止动态模式”提前结束"
|
||||
: completed
|
||||
? `${mode.name}已直接执行保存的控制指令流`
|
||||
: `${mode.name}未完成,请查看执行结果`);
|
||||
} catch (error) {
|
||||
@@ -6985,10 +7167,17 @@ function applyModeCommand(command, modeProfileId = null) {
|
||||
showToast("AI助手正在处理上一条指令,请稍候");
|
||||
return false;
|
||||
}
|
||||
const speech = window.CognitiveAgentSpeech;
|
||||
if (["capturing", "finalizing"].includes(speech?.phase)
|
||||
|| (speech?.phase === "arming" && speech?.inputMode === "manual")) {
|
||||
showToast("请等待语音整理完成后再发送");
|
||||
return false;
|
||||
}
|
||||
if ($("#runButton")?.disabled) {
|
||||
showToast("上一条指令仍在执行,请稍候");
|
||||
return false;
|
||||
}
|
||||
hideModeActionPopover({ immediate: true });
|
||||
const mode = modeProfiles().find((item) => item.id === modeProfileId);
|
||||
setModeRailSelection(mode);
|
||||
closeModeGallery({ restoreFocus: false });
|
||||
@@ -8057,6 +8246,9 @@ const CONFIG_DOMAIN_META = {
|
||||
const CONFIG_CAPABILITY_LABELS = {
|
||||
"seat.set_ventilation": "设置座椅通风",
|
||||
"seat.set_heating": "设置座椅加热",
|
||||
"seat.set_driver_ventilation_native": "设置主驾座椅通风",
|
||||
"seat.set_driver_heating_native": "设置主驾座椅加热",
|
||||
"seat.set_driver_massage_native": "设置主驾座椅按摩",
|
||||
"seat.set_longitudinal": "调节座椅前后位置",
|
||||
"seat.set_longitudinal_position": "调节座椅前后位置",
|
||||
"seat.set_cushion_tilt": "调节座椅高度",
|
||||
@@ -8087,6 +8279,8 @@ const CONFIG_CAPABILITY_LABELS = {
|
||||
"ambient_light.set_illumination": "设置氛围灯颜色与亮度",
|
||||
"ambient_light.set_color": "设置氛围灯颜色",
|
||||
"ambient_light.set_brightness": "设置氛围灯亮度",
|
||||
"ambient_light.set_brightness_level": "设置氛围灯亮度",
|
||||
"ambient_light.set_color_index": "设置氛围灯色号",
|
||||
"ambient_light.set_mode": "设置氛围灯模式",
|
||||
"roof.set_transmittance": "设置天幕透光率",
|
||||
"roof.set_shade_position": "设置遮阳帘位置",
|
||||
@@ -8095,6 +8289,10 @@ const CONFIG_CAPABILITY_LABELS = {
|
||||
};
|
||||
|
||||
const CONFIG_PARAMETER_LABELS = {
|
||||
action: "动作",
|
||||
strength: "力度",
|
||||
color_index: "色号",
|
||||
brightness_level: "亮度档位",
|
||||
power: "电源",
|
||||
state: "状态",
|
||||
level: "档位",
|
||||
@@ -8381,6 +8579,24 @@ function renderExecution(result) {
|
||||
const summary = $("#executionSummary");
|
||||
const list = $("#resultList");
|
||||
list.replaceChildren();
|
||||
const scheduleExecution = modeScheduleExecutionView(result);
|
||||
if (scheduleExecution) {
|
||||
summary.className = `execution-summary ${scheduleExecution.state === "processing" ? "partial" : scheduleExecution.state}`;
|
||||
const icon = document.createElement("i");
|
||||
icon.dataset.lucide = scheduleExecution.state === "processing" ? "timer" : "circle-check";
|
||||
const copy = document.createElement("div");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = scheduleExecution.title;
|
||||
const detail = document.createElement("span");
|
||||
detail.textContent = scheduleExecution.detail;
|
||||
copy.append(title, detail);
|
||||
summary.replaceChildren(icon, copy);
|
||||
$("#resultNavMeta").textContent = scheduleExecution.badge;
|
||||
$("#resultPanelState").textContent = scheduleExecution.badge;
|
||||
$("#resultPanelState").className = `workspace-panel-state ${scheduleExecution.state}`;
|
||||
refreshIcons();
|
||||
return;
|
||||
}
|
||||
const report = result.execution_report;
|
||||
if (!report) {
|
||||
summary.className = "execution-summary failed";
|
||||
@@ -8656,6 +8872,7 @@ window.addEventListener("cognitive-agent:extension-vehicle-result", (event) => {
|
||||
|
||||
function renderResult(result) {
|
||||
state.result = result;
|
||||
const scheduleExecution = modeScheduleExecutionView(result);
|
||||
const reportMode = result.execution_report?.spec?.mode;
|
||||
const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback;
|
||||
const submittedUnverified = executionVerificationStatus(result) === "submitted_unverified";
|
||||
@@ -8690,7 +8907,9 @@ function renderResult(result) {
|
||||
};
|
||||
const peripheralRoute = String(result.orchestration_route || "")
|
||||
.startsWith("nfc_peripheral");
|
||||
$("#resultTitle").textContent = peripheralRoute && executionSucceeded
|
||||
$("#resultTitle").textContent = scheduleExecution
|
||||
? `${result.mode_profile?.name ? `${result.mode_profile.name} · ` : ""}${scheduleExecution.title}`
|
||||
: peripheralRoute && executionSucceeded
|
||||
? `${result.peripheral?.name || "第三方外设"} · ${result.peripheral?.action || "动作已执行"}`
|
||||
: partiallySucceeded
|
||||
? `${domains.length} 域场景部分完成`
|
||||
@@ -8714,7 +8933,7 @@ function renderResult(result) {
|
||||
$("#requestMeta").textContent += ` / reasoning:${result.reasoning_effort_used}`;
|
||||
}
|
||||
setRunStatus(
|
||||
partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed",
|
||||
scheduleExecution?.state || (partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed"),
|
||||
submittedUnverified ? "SUBMITTED_UNVERIFIED" : result.status.toUpperCase(),
|
||||
);
|
||||
renderDecision(result);
|
||||
@@ -9293,12 +9512,17 @@ window.CognitiveAgentConsole = {
|
||||
&& expectedSignatures.every((signature, index) => signature === actualSignatures[index]));
|
||||
if (payload.status !== "succeeded"
|
||||
|| (expectedOperationCount > 0 && actualOperationCount < expectedOperationCount)
|
||||
|| !sameActions) {
|
||||
|| !sameActions || !modeScheduleTrialMatches(draft, payload)) {
|
||||
throw new Error("试运行未完整覆盖草稿动作,请重新生成模式预览。");
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
revealModeProfile,
|
||||
updateScheduleExecutionStatus(modeProfileId, status) {
|
||||
if (!modeProfileId || (state.pendingModeProfileId && state.pendingModeProfileId !== modeProfileId)) return;
|
||||
const terminal = { completed: "ended", failed: "failed", cancelled: "stopped", stopped: "stopped", idle: "idle" }[status];
|
||||
if (terminal) setModeExecutionState(modeProfileId, terminal);
|
||||
},
|
||||
notify(message) {
|
||||
showToast(String(message));
|
||||
},
|
||||
@@ -9321,9 +9545,11 @@ window.addEventListener("cognitive-agent:request-complete", (event) => {
|
||||
if (!modeProfileId) return;
|
||||
state.pendingModeProfileId = null;
|
||||
const result = event.detail?.result;
|
||||
const scheduleRunning = !event.detail?.error
|
||||
&& modeScheduleExecutionView(result)?.state === "processing";
|
||||
const succeeded = !event.detail?.error
|
||||
&& ["succeeded", "partially_succeeded"].includes(String(result?.status || ""));
|
||||
setModeExecutionState(modeProfileId, succeeded ? "succeeded" : "failed");
|
||||
setModeExecutionState(modeProfileId, scheduleRunning ? "started" : succeeded ? "succeeded" : "failed");
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
|
||||
@@ -551,6 +551,111 @@
|
||||
padding: 9px 10px 8px;
|
||||
}
|
||||
|
||||
.assistant-mode-schedule-summary,
|
||||
.assistant-mode-schedule-hint {
|
||||
margin: 0;
|
||||
padding: 9px 12px;
|
||||
color: var(--assistant-muted, #64748b);
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.assistant-mode-schedule-summary {
|
||||
color: var(--status-info-foreground, #3764d9);
|
||||
background: var(--status-info-soft, #eef3ff);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.assistant-mode-schedule-phase + .assistant-mode-schedule-phase,
|
||||
.assistant-mode-schedule-hint {
|
||||
border-top: 1px solid var(--assistant-border, #e5eaf1);
|
||||
}
|
||||
|
||||
.assistant-mode-schedule-phase > header {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 10px 12px 5px;
|
||||
color: var(--assistant-heading, #334155);
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.assistant-mode-schedule-phase > header small {
|
||||
color: var(--assistant-muted, #64748b);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.assistant-mode-draft-scheduled .assistant-mode-draft-row {
|
||||
grid-template-columns: 18px minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.assistant-mode-draft-scheduled .assistant-mode-draft-row > strong {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mode-gallery-card-shell:has(> .mode-gallery-schedule-details) > .mode-gallery-card {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mode-gallery-schedule-details {
|
||||
padding: 9px 12px;
|
||||
color: var(--foreground, #334155);
|
||||
background: var(--card, #fff);
|
||||
border: 1px solid var(--border, #e5eaf1);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mode-gallery-schedule-details > summary {
|
||||
cursor: pointer;
|
||||
color: var(--primary, #3764d9);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mode-gallery-schedule-details ol {
|
||||
margin: 10px 0;
|
||||
padding-left: 19px;
|
||||
}
|
||||
|
||||
.mode-gallery-schedule-details li + li {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mode-gallery-schedule-details li > strong,
|
||||
.mode-gallery-schedule-details li > small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mode-gallery-schedule-details small,
|
||||
.mode-gallery-schedule-details > p {
|
||||
color: var(--muted-foreground, #64748b);
|
||||
}
|
||||
|
||||
.mode-gallery-schedule-details p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.mode-chip-action-popover li.mode-schedule-popover-summary {
|
||||
display: block;
|
||||
color: var(--status-info-foreground, #3764d9);
|
||||
background: var(--status-info-soft, #eef3ff);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mode-schedule-popover-phase {
|
||||
color: var(--primary, #3764d9);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.assistant-mode-draft-actions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -1478,7 +1583,14 @@
|
||||
}
|
||||
|
||||
.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-operation-shell {
|
||||
top: 60px;
|
||||
/* Share the content row on compact screens, never cover the mode rail. */
|
||||
position: relative;
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
width: 100%;
|
||||
top: auto;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-module-switcher {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
let pendingMessage = null;
|
||||
let latestControlResult = null;
|
||||
let activeModeDraftContext = null;
|
||||
let activeSchedulePoll = null;
|
||||
let initialized = false;
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
@@ -176,11 +177,13 @@
|
||||
if (!compact || !context?.name) return false;
|
||||
const politePrefix = "(?:(?:请|麻烦)(?:帮我|帮忙|给我)?|帮我|帮忙|给我)?";
|
||||
const prefixUpdateVerb = "(?:修改一下|修改|调整|更改|更新|改一下|变更)";
|
||||
const changePredicate = "(?:修改|改成|改为|更改|更新|变更|变为|变成|换成|调整|调节|调高|调低|调到|调成|调为|调至|设置|设为|设成|设到|设至|打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|复位|半开|开到|关到|拉开|拉上|上锁|解锁)";
|
||||
const addPredicate = "(?:加入|添加|增加|补充|加上|追加|新增)";
|
||||
const changePredicate = `(?:${addPredicate}|修改|改成|改为|更改|更新|变更|变为|变成|换成|调整|调节|调高|调低|调到|调成|调为|调至|设置|设为|设成|设到|设至|打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|复位|半开|开到|关到|拉开|拉上|上锁|解锁)`;
|
||||
const modeName = "([^,。!?!?、;;::]{1,64}?模式)";
|
||||
const modePossessiveBoundary = "(?:中的|里的|内的|之中的|的|中|里|内)";
|
||||
const modeMemberBoundary = `(?:${modePossessiveBoundary}|[,,::])`;
|
||||
const question = /为什么|什么|怎么|如何|介绍|解释|作用|区别|原理|哪些|多少|几(?:度|档|级|个|项)?|是否|能否|可不可以|合适吗|合理吗|会怎样|列出|告诉我|[??]|(?:吗|么|呢)[。.!!]?$/;
|
||||
const declinedAddition = new RegExp(`^(?:先|暂时)?(?:不要|别|不用|不必|无需|取消|撤销)(?:再|继续)?${addPredicate}`);
|
||||
const candidates = [
|
||||
{
|
||||
pattern: new RegExp(`^${politePrefix}${prefixUpdateVerb}${modeName}${modeMemberBoundary}[,,::]?(.*)$`),
|
||||
@@ -194,14 +197,29 @@
|
||||
pattern: new RegExp(`^${politePrefix}${modeName}${modeMemberBoundary}[,,::]?(.+)$`),
|
||||
predicateRequired: true,
|
||||
},
|
||||
{
|
||||
pattern: new RegExp(`^${politePrefix}(?:在|给|为|往|向)${modeName}(?:${modePossessiveBoundary})?[,,::]?(.+)$`),
|
||||
predicateRequired: true,
|
||||
},
|
||||
{
|
||||
pattern: new RegExp(`^${politePrefix}${modeName}((?:再|也|还)?${addPredicate}.+)$`),
|
||||
predicateRequired: true,
|
||||
},
|
||||
{
|
||||
pattern: new RegExp(`^${politePrefix}(?:把|将)(.+?)${addPredicate}(?:到|进)?${modeName}(?:中|里|内)?[。.!!]?$`),
|
||||
predicateRequired: false,
|
||||
nameIndex: 2,
|
||||
changeIndex: 1,
|
||||
},
|
||||
];
|
||||
return candidates.some(({ pattern, predicateRequired }) => {
|
||||
return candidates.some(({ pattern, predicateRequired, nameIndex = 1, changeIndex = 2 }) => {
|
||||
const matched = compact.match(pattern);
|
||||
const change = String(matched?.[2] || "");
|
||||
const change = String(matched?.[changeIndex] || "");
|
||||
return Boolean(
|
||||
matched
|
||||
&& normalizeModeName(matched[1]) === normalizeModeName(context.name)
|
||||
&& normalizeModeName(matched[nameIndex]) === normalizeModeName(context.name)
|
||||
&& !question.test(change)
|
||||
&& !declinedAddition.test(change)
|
||||
&& (!predicateRequired || new RegExp(changePredicate).test(change))
|
||||
);
|
||||
});
|
||||
@@ -894,7 +912,7 @@
|
||||
const consumed = new Set();
|
||||
const rows = [];
|
||||
const targetZone = (action) => String(
|
||||
action?.target?.instance || action?.target?.zone || action?.target || "",
|
||||
action?.target?.instance || action?.target?.zone || action?.target_scope || action?.target || "",
|
||||
);
|
||||
const parameters = (action) => (
|
||||
action?.parameters && typeof action.parameters === "object" ? action.parameters : {}
|
||||
@@ -965,6 +983,10 @@
|
||||
const capability = String(action.capability || "");
|
||||
const domain = String(action.domain || "");
|
||||
const zone = targetZone(action);
|
||||
const zoneLabel = {
|
||||
driver: "主驾", passenger: "副驾", front_left: "主驾", front_right: "副驾",
|
||||
front_row: "前排", rear_row: "后排", rear_left: "左后", rear_right: "右后",
|
||||
}[zone] || "";
|
||||
const params = parameters(action);
|
||||
let label = capability || domain || "座舱设备";
|
||||
let value = "已配置";
|
||||
@@ -999,35 +1021,86 @@
|
||||
"seat.set_cushion_tilt": "坐垫角度",
|
||||
"seat.set_backrest_recline": "靠背角度",
|
||||
"seat.set_legrest_height": "腿托高度",
|
||||
"seat.set_ventilation": "座椅通风",
|
||||
"seat.set_heating": "座椅加热",
|
||||
"seat.set_driver_ventilation_native": "座椅通风",
|
||||
"seat.set_driver_heating_native": "座椅加热",
|
||||
"seat.set_driver_massage_native": "座椅按摩",
|
||||
};
|
||||
label = `${seatLabel}${seatCapabilityLabels[capability] || "座椅"}`;
|
||||
value = params.position_pct != null
|
||||
? `${params.position_pct}%${params.angle_deg != null ? `(${params.angle_deg}°)` : ""}`
|
||||
: motionLabel(params.state || params.mode_id);
|
||||
if (["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"].includes(capability)) {
|
||||
// CH1 driver controls encode high as 1 and low as 2; passenger
|
||||
// comfort levels use a different scale and must not reuse this map.
|
||||
const level = { 1: "高档", 2: "低档" }[params.level ?? 2] || "档位待确认";
|
||||
value = params.state === "off" ? "关闭"
|
||||
: params.state === "on" ? `开启 · ${level}` : "开关状态待确认";
|
||||
} else if (["seat.set_ventilation", "seat.set_heating"].includes(capability)) {
|
||||
value = params.state === "off" ? "关闭"
|
||||
: params.state === "on"
|
||||
? `开启${params.level != null ? ` · ${params.level}档` : ""}`
|
||||
: "开关状态待确认";
|
||||
} else if (capability === "seat.set_driver_massage_native") {
|
||||
const massageModes = {
|
||||
wave: "波浪", pulse: "脉冲", stretch: "舒展", slack: "舒缓", relax: "放松",
|
||||
};
|
||||
const massageStrengths = { 1: "轻柔", 2: "中等", 3: "较强" };
|
||||
value = params.action === "off" ? "关闭"
|
||||
: params.action === "mode"
|
||||
? (massageModes[params.mode] ? `${massageModes[params.mode]}模式` : "按摩模式待确认")
|
||||
: params.action === "strength"
|
||||
? (massageStrengths[params.strength] ? `${massageStrengths[params.strength]}力度` : "按摩力度待确认")
|
||||
: "按摩动作待确认";
|
||||
}
|
||||
icon = "armchair";
|
||||
order = instructionOrder("座椅", "副驾", "主驾");
|
||||
} else if (capability === "climate.set_temperature") {
|
||||
label = "空调温度";
|
||||
label = `${zoneLabel}空调温度`;
|
||||
value = `${params.temperature_c ?? "--"}℃`;
|
||||
icon = "thermometer";
|
||||
order = instructionOrder("空调", "温度");
|
||||
} else if (domain === "climate") {
|
||||
const cycleLabels = { internal: "内循环", external: "外循环", auto: "自动循环" };
|
||||
const climateModeLabels = {
|
||||
"climate.set_cycle_mode": { internal: "内循环", external: "外循环" },
|
||||
"climate.set_control_mode": { manual: "手动控制", auto: "自动控制" },
|
||||
"climate.set_outlet_mode": {
|
||||
face: "吹面", face_feet: "吹面 / 吹脚", feet: "吹脚",
|
||||
feet_defrost: "吹脚 / 除霜", defrost: "除霜",
|
||||
face_feet_defrost: "吹面 / 吹脚 / 除霜", face_defrost: "吹面 / 除霜",
|
||||
},
|
||||
};
|
||||
label = capability === "climate.set_fan_level" ? "空调风量"
|
||||
: capability === "climate.set_cycle_mode" ? "空调循环" : "空调";
|
||||
: capability === "climate.set_cycle_mode" ? "空调循环"
|
||||
: capability === "climate.set_control_mode" ? "空调控制模式"
|
||||
: capability === "climate.set_outlet_mode" ? "空调出风模式" : "空调";
|
||||
label = `${zoneLabel}${label}`;
|
||||
value = params.level != null ? `${params.level}档`
|
||||
: params.mode != null ? (cycleLabels[String(params.mode)] || String(params.mode))
|
||||
: params.mode != null ? (climateModeLabels[capability]?.[String(params.mode)] || String(params.mode))
|
||||
: motionLabel(params.state);
|
||||
icon = "fan";
|
||||
order = instructionOrder("空调", "风量");
|
||||
} else if (capability === "interior_light.set_reading_power") {
|
||||
label = "阅读灯";
|
||||
label = `${zoneLabel}阅读灯`;
|
||||
value = motionLabel(params.state);
|
||||
icon = "book-open";
|
||||
order = instructionOrder("阅读灯", "灯光");
|
||||
} else if (["interior_light", "ambient_light"].includes(domain)) {
|
||||
label = domain === "ambient_light" ? "氛围灯" : "室内灯";
|
||||
value = motionLabel(params.state);
|
||||
if (capability === "ambient_light.set_brightness_level") {
|
||||
label = "氛围灯亮度";
|
||||
value = params.brightness_level != null ? `${params.brightness_level}档` : "亮度档位待确认";
|
||||
} else if (capability === "ambient_light.set_color_index") {
|
||||
label = "氛围灯颜色";
|
||||
value = params.color_index != null ? `色号 ${params.color_index}` : "颜色待确认";
|
||||
} else if (capability === "ambient_light.set_illumination") {
|
||||
value = [
|
||||
params.color_hex != null ? `颜色 ${params.color_hex}` : "颜色待确认",
|
||||
params.brightness_pct != null ? `亮度 ${params.brightness_pct}%` : "亮度待确认",
|
||||
].join(" · ");
|
||||
}
|
||||
icon = "lightbulb";
|
||||
order = instructionOrder(label, "灯光");
|
||||
}
|
||||
@@ -1036,6 +1109,63 @@
|
||||
return rows.sort((left, right) => left.order - right.order);
|
||||
}
|
||||
|
||||
function modeDraftScheduleView(draft) {
|
||||
const schedule = draft?.schedule;
|
||||
if (
|
||||
schedule?.schema !== "cognitive-agent-mode-schedule/v1"
|
||||
|| !Array.isArray(schedule.phases) || !schedule.phases.length
|
||||
|| !Number.isInteger(schedule.repeat_count)
|
||||
|| schedule.repeat_count < 1 || schedule.repeat_count > 20
|
||||
|| schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms)
|
||||
|| phase.duration_ms <= 0 || !Array.isArray(phase.operations))
|
||||
) return null;
|
||||
const durationLabel = (durationMs) => {
|
||||
const minutes = Math.floor(durationMs / 60000);
|
||||
const seconds = Number(((durationMs % 60000) / 1000).toFixed(3));
|
||||
return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`;
|
||||
};
|
||||
let offsetMs = 0;
|
||||
const phases = schedule.phases.map((phase, index) => {
|
||||
// Each phase is rendered separately, without instruction-based grouping:
|
||||
// the same seat or temperature can have a different target later on.
|
||||
const rows = modeDraftRows({ instruction: "" }, phase.operations.map((operation) => ({
|
||||
...operation,
|
||||
target: operation.target || (operation.domain === "seat"
|
||||
? { instance: operation.target_scope }
|
||||
: { zone: operation.target_scope }),
|
||||
})));
|
||||
const view = {
|
||||
id: String(phase.id || `phase-${index + 1}`),
|
||||
name: String(phase.name || `阶段 ${index + 1}`),
|
||||
timing: `+${durationLabel(offsetMs)} 开始 · 持续 ${durationLabel(phase.duration_ms)}`,
|
||||
rows,
|
||||
};
|
||||
offsetMs += phase.duration_ms;
|
||||
return view;
|
||||
});
|
||||
return {
|
||||
phases,
|
||||
operationCount: schedule.phases.reduce((count, phase) => count + phase.operations.length, 0),
|
||||
summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${durationLabel(offsetMs * schedule.repeat_count)}`,
|
||||
stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚。各阶段按顺序执行,时间为每轮的相对时间。",
|
||||
};
|
||||
}
|
||||
|
||||
function appendModeDraftRows(container, rows) {
|
||||
rows.forEach((item) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "assistant-mode-draft-row";
|
||||
const icon = document.createElement("i");
|
||||
icon.dataset.lucide = item.icon;
|
||||
const label = document.createElement("span");
|
||||
label.textContent = item.label;
|
||||
const value = document.createElement("strong");
|
||||
value.textContent = item.value;
|
||||
row.append(icon, label, value);
|
||||
container.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveModeProfileDraft(message, card, draft, buttons, status) {
|
||||
buttons.forEach((button) => { button.disabled = true; });
|
||||
card.classList.add("is-busy");
|
||||
@@ -1151,23 +1281,39 @@
|
||||
headingCopy.append(eyebrow, title);
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "assistant-mode-draft-badge";
|
||||
badge.textContent = `${Number(draft.operation_count) || 0} 项动作`;
|
||||
const scheduleView = modeDraftScheduleView(draft);
|
||||
badge.textContent = `${scheduleView?.operationCount ?? (Number(draft.operation_count) || 0)} 项动作`;
|
||||
heading.append(headingCopy, badge);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "assistant-mode-draft-list";
|
||||
modeDraftRows(draft, actions).forEach((item) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "assistant-mode-draft-row";
|
||||
const icon = document.createElement("i");
|
||||
icon.dataset.lucide = item.icon;
|
||||
const label = document.createElement("span");
|
||||
label.textContent = item.label;
|
||||
const value = document.createElement("strong");
|
||||
value.textContent = item.value;
|
||||
row.append(icon, label, value);
|
||||
list.append(row);
|
||||
});
|
||||
if (scheduleView) {
|
||||
card.classList.add("assistant-mode-draft-scheduled");
|
||||
const summary = document.createElement("p");
|
||||
summary.className = "assistant-mode-schedule-summary";
|
||||
summary.textContent = scheduleView.summary;
|
||||
list.append(summary);
|
||||
scheduleView.phases.forEach((phase, index) => {
|
||||
const section = document.createElement("section");
|
||||
section.className = "assistant-mode-schedule-phase";
|
||||
section.dataset.modeSchedulePhase = phase.id;
|
||||
const phaseHeading = document.createElement("header");
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = `${index + 1}. ${phase.name}`;
|
||||
const timing = document.createElement("small");
|
||||
timing.textContent = phase.timing;
|
||||
phaseHeading.append(name, timing);
|
||||
section.append(phaseHeading);
|
||||
appendModeDraftRows(section, phase.rows);
|
||||
list.append(section);
|
||||
});
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "assistant-mode-schedule-hint";
|
||||
hint.textContent = scheduleView.stopHint;
|
||||
list.append(hint);
|
||||
} else {
|
||||
appendModeDraftRows(list, modeDraftRows(draft, actions));
|
||||
}
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "assistant-mode-draft-actions";
|
||||
@@ -1267,6 +1413,90 @@
|
||||
}
|
||||
}
|
||||
|
||||
function stopScheduleStatusPoll() {
|
||||
if (!activeSchedulePoll) return;
|
||||
window.clearTimeout(activeSchedulePoll.timer);
|
||||
activeSchedulePoll.controller?.abort();
|
||||
activeSchedulePoll = null;
|
||||
}
|
||||
|
||||
function startScheduleStatusPoll(payload, message) {
|
||||
stopScheduleStatusPoll();
|
||||
const vehicleId = String(payload?.vehicle_id || payload?.vehicle?.id
|
||||
|| payload?.mode_profile?.vehicle_id || $("#vehicleSelect")?.value || "");
|
||||
if (!vehicleId) return;
|
||||
const poll = {
|
||||
vehicleId, conversationId: conversationId(), message,
|
||||
sessionId: String(payload?.schedule_execution?.session_id || ""),
|
||||
runId: String(payload?.schedule_execution?.run_id || ""),
|
||||
modeProfileId: String(payload?.mode_profile?.id || ""),
|
||||
timer: null, controller: null,
|
||||
};
|
||||
activeSchedulePoll = poll;
|
||||
const isCurrent = () => activeSchedulePoll === poll
|
||||
&& conversationId() === poll.conversationId
|
||||
&& (!$("#vehicleSelect")?.value || $("#vehicleSelect").value === poll.vehicleId);
|
||||
const update = async () => {
|
||||
if (!isCurrent()) {
|
||||
if (activeSchedulePoll === poll) stopScheduleStatusPoll();
|
||||
return;
|
||||
}
|
||||
poll.controller = new AbortController();
|
||||
try {
|
||||
const response = await window.CognitiveAgentNetwork.request(
|
||||
`/api/modes/schedule/status?vehicle_id=${encodeURIComponent(poll.vehicleId)}`,
|
||||
{ method: "GET", signal: poll.controller.signal, timeoutMs: 8000, retries: 0 },
|
||||
);
|
||||
if (!isCurrent()) return;
|
||||
if (response.status === 401) {
|
||||
setActionStatus(message, "登录状态已失效,无法继续查询时间轴状态;请重新登录查看。", "failed");
|
||||
stopScheduleStatusPoll();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const status = await response.json();
|
||||
if (!isCurrent()) return;
|
||||
if (status.vehicle_id && String(status.vehicle_id) !== poll.vehicleId) {
|
||||
throw new Error("时间轴状态车型不匹配");
|
||||
}
|
||||
if ((poll.runId && status.run_id && String(status.run_id) !== poll.runId)
|
||||
|| (poll.sessionId && status.session_id && String(status.session_id) !== poll.sessionId)) {
|
||||
setActionStatus(message, "当前时间轴已被新的运行任务替换,请查看最新模式消息。", "working");
|
||||
stopScheduleStatusPoll();
|
||||
return;
|
||||
}
|
||||
const terminal = ["completed", "failed", "cancelled", "stopped", "idle"].includes(status.status);
|
||||
if (status.status === "running") {
|
||||
const phase = Number.isInteger(status.phase_index) && status.phase_index >= 0
|
||||
? ` · 阶段 ${status.phase_index + 1}` : "";
|
||||
const repeat = Number.isInteger(status.repeat_index) && status.repeat_index >= 0
|
||||
? ` · 第 ${status.repeat_index + 1} 轮` : "";
|
||||
setActionStatus(message, `动态时间轴执行中${repeat}${phase}。可输入“停止动态模式”;已执行状态不自动回滚。`, "working");
|
||||
} else if (terminal) {
|
||||
const stopped = ["cancelled", "stopped"].includes(status.status);
|
||||
const failed = status.status === "failed";
|
||||
const copy = failed
|
||||
? `动态时间轴执行失败:${status.error || "某个阶段未通过执行校验"}。后续阶段不再执行,已执行状态不自动回滚。`
|
||||
: stopped
|
||||
? "动态时间轴已停止,后续阶段不再执行;已执行状态不自动回滚。"
|
||||
: status.status === "completed"
|
||||
? "动态时间轴已结束,全部计划阶段已处理;请以车辆反馈确认设备实际状态。"
|
||||
: "当前没有正在运行的动态时间轴。";
|
||||
setActionStatus(message, copy, failed ? "failed" : "success");
|
||||
window.CognitiveAgentConsole?.updateScheduleExecutionStatus?.(poll.modeProfileId, status.status);
|
||||
stopScheduleStatusPoll();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrent() || poll.controller?.signal.aborted) return;
|
||||
setActionStatus(message, "暂时无法获取时间轴状态,正在重新查询;需要停止时可输入“停止动态模式”。", "working");
|
||||
} finally {
|
||||
if (isCurrent()) poll.timer = window.setTimeout(update, 2000);
|
||||
}
|
||||
};
|
||||
poll.timer = window.setTimeout(update, 2000);
|
||||
}
|
||||
|
||||
function applyAssistantEvent(event, message) {
|
||||
if (event.type === "validated_control_commands" && event.payload) {
|
||||
window.dispatchEvent(new CustomEvent(
|
||||
@@ -1295,14 +1525,20 @@
|
||||
if (event.type === "action.completed" || event.type === "work.completed") {
|
||||
latestControlResult = event.payload || latestControlResult;
|
||||
const completed = event.success !== false;
|
||||
const scheduleRunning = completed
|
||||
&& event.payload?.orchestration_route === "mode_schedule_execute"
|
||||
&& (event.payload?.status === "running" || event.payload?.schedule_execution?.status === "running");
|
||||
const defaultText = event.type === "work.completed"
|
||||
? (completed ? "AI助手已完成工作" : "AI助手未能完成工作")
|
||||
: (completed ? "座舱控制已完成" : "座舱控制未完成");
|
||||
setActionStatus(
|
||||
message,
|
||||
event.display_text || defaultText,
|
||||
completed ? "success" : "failed",
|
||||
scheduleRunning
|
||||
? "已启动动态时间轴;后续动作将分阶段执行。可输入“停止动态模式”提前结束,已执行的设备状态不会自动回滚。"
|
||||
: event.display_text || defaultText,
|
||||
scheduleRunning ? "working" : completed ? "success" : "failed",
|
||||
);
|
||||
if (scheduleRunning) startScheduleStatusPoll(event.payload, message);
|
||||
if (event.payload) {
|
||||
window.CognitiveAgentConsole?.applySyncedResult?.(event.payload);
|
||||
appendDispatchedControls(message, event.payload);
|
||||
@@ -1574,6 +1810,8 @@
|
||||
: beginModeExecution(profile, { command: commandText });
|
||||
const message = activeHandle.message;
|
||||
const status = String(result?.status || "");
|
||||
const scheduleRunning = status === "running"
|
||||
&& result?.schedule_execution?.status === "running";
|
||||
const dryRun = result?.execution_mode === "dry_run";
|
||||
const waitingForFeedback = result?.execution_outcome === "submitted_unverified";
|
||||
|
||||
@@ -1583,6 +1821,10 @@
|
||||
if (error) {
|
||||
content = `${modeName}执行失败:${error.message || "请稍后重试。"}`;
|
||||
statusText = "功能模式执行失败";
|
||||
} else if (scheduleRunning) {
|
||||
content = `已启动动态时间轴:${commandText}。后续动作将分阶段执行。`;
|
||||
statusText = "动态时间轴执行中,可输入“停止动态模式”提前结束";
|
||||
statusState = "working";
|
||||
} else if (status === "succeeded") {
|
||||
content = dryRun
|
||||
? `已完成模拟编排:${commandText}。`
|
||||
@@ -1603,6 +1845,7 @@
|
||||
setMessageContent(message, content, { final: true });
|
||||
setActionStatus(message, statusText, statusState);
|
||||
if (result) appendDispatchedControls(message, result);
|
||||
if (scheduleRunning) startScheduleStatusPoll(result, message);
|
||||
persistMessages();
|
||||
return activeHandle;
|
||||
}
|
||||
@@ -1689,9 +1932,15 @@
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (!target?.closest("#newConversation")) return;
|
||||
event.preventDefault?.();
|
||||
stopScheduleStatusPoll();
|
||||
resetConversation();
|
||||
}, true);
|
||||
|
||||
document.addEventListener("change", (event) => {
|
||||
if (event.target?.id === "vehicleSelect") stopScheduleStatusPoll();
|
||||
});
|
||||
window.addEventListener("pagehide", stopScheduleStatusPoll);
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initialize);
|
||||
window.CognitiveAgentAssistant = {
|
||||
submit: submitAssistant,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
.console-v2 .firmware-workbench-layer {
|
||||
position: fixed;
|
||||
z-index: 5000;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 68px 20px 20px;
|
||||
background: color-mix(in oklch, var(--background) 58%, transparent);
|
||||
backdrop-filter: blur(6px) saturate(.9);
|
||||
-webkit-backdrop-filter: blur(6px) saturate(.9);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.console-v2 .firmware-workbench-layer[hidden] { display: none !important; }
|
||||
.console-v2 .firmware-workbench-page {
|
||||
width: min(1180px, 100%);
|
||||
height: min(664px, 100%);
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 76px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
color: var(--foreground);
|
||||
background: color-mix(in oklch, var(--card) 97%, var(--background));
|
||||
border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 28px 70px color-mix(in oklch, var(--foreground) 22%, transparent), 0 4px 18px color-mix(in oklch, var(--primary) 10%, transparent), inset 0 1px 0 color-mix(in oklch, var(--primary-foreground) 85%, transparent);
|
||||
animation: firmware-workbench-enter 180ms ease-out both;
|
||||
}
|
||||
.console-v2 .firmware-workbench-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 14px 18px 13px 20px;
|
||||
border-bottom: 1px solid color-mix(in oklch, var(--primary) 10%, var(--border));
|
||||
background: color-mix(in oklch, var(--card) 85%, transparent);
|
||||
}
|
||||
.console-v2 .firmware-workbench-heading { min-width: 0; display: flex; align-items: center; gap: 12px; }
|
||||
.console-v2 .firmware-workbench-heading > span {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex: 0 0 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--primary);
|
||||
background: color-mix(in oklch, var(--primary) 10%, var(--card));
|
||||
border: 1px solid color-mix(in oklch, var(--primary) 22%, var(--border));
|
||||
border-radius: 15px;
|
||||
}
|
||||
.console-v2 .firmware-workbench-heading svg { width: 24px; height: 24px; stroke-width: 1.8; }
|
||||
.console-v2 .firmware-workbench-heading h2 { margin: 0; color: var(--heading-foreground, var(--foreground)); font-size: 20px; line-height: 1.2; letter-spacing: -.025em; }
|
||||
.console-v2 #firmwareWorkbenchClose {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
flex: 0 0 38px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: var(--foreground);
|
||||
background: color-mix(in oklch, var(--card) 88%, var(--secondary));
|
||||
border: 1px solid color-mix(in oklch, var(--primary) 13%, var(--border));
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.console-v2 #firmwareWorkbenchClose:hover,
|
||||
.console-v2 #firmwareWorkbenchClose:focus-visible { color: var(--primary); background: var(--accent); outline: 3px solid color-mix(in oklch, var(--ring) 16%, transparent); }
|
||||
.console-v2 #firmwareWorkbenchClose svg { width: 18px; height: 18px; }
|
||||
.console-v2 .firmware-workbench-frame { width: 100%; height: 100%; display: block; border: 0; background: transparent; }
|
||||
@keyframes firmware-workbench-enter { from { opacity: 0; transform: translateY(8px) scale(.992); } to { opacity: 1; transform: none; } }
|
||||
@media (max-width: 760px) {
|
||||
.console-v2 .firmware-workbench-layer { padding: 54px 8px 8px; }
|
||||
.console-v2 .firmware-workbench-page { height: 100%; border-radius: 17px; grid-template-rows: 64px minmax(0, 1fr); }
|
||||
.console-v2 .firmware-workbench-header { padding: 9px 11px; }
|
||||
.console-v2 .firmware-workbench-heading > span { width: 40px; height: 40px; flex-basis: 40px; border-radius: 12px; }
|
||||
.console-v2 .firmware-workbench-heading h2 { font-size: 17px; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const state = { open: false, returnFocus: null, backgroundInert: [] };
|
||||
|
||||
function setBackgroundInert(inert) {
|
||||
const layer = document.querySelector("#firmwareWorkbenchLayer");
|
||||
if (!layer) return;
|
||||
if (inert) {
|
||||
state.backgroundInert = [...document.body.children]
|
||||
.filter((element) => element !== layer && !["SCRIPT", "STYLE", "LINK"].includes(element.tagName))
|
||||
.map((element) => ({ element, wasInert: element.hasAttribute("inert") }));
|
||||
state.backgroundInert.forEach(({ element }) => element.toggleAttribute("inert", true));
|
||||
return;
|
||||
}
|
||||
state.backgroundInert.forEach(({ element, wasInert }) => element.toggleAttribute("inert", wasInert));
|
||||
state.backgroundInert = [];
|
||||
}
|
||||
|
||||
function setOpen(open) {
|
||||
const next = Boolean(open);
|
||||
const layer = document.querySelector("#firmwareWorkbenchLayer");
|
||||
const trigger = document.querySelector("#firmwareWorkbenchToggle");
|
||||
if (!layer || !trigger || state.open === next) return;
|
||||
state.open = next;
|
||||
trigger.classList.toggle("active", next);
|
||||
trigger.setAttribute("aria-pressed", String(next));
|
||||
trigger.setAttribute("aria-expanded", String(next));
|
||||
trigger.setAttribute("aria-label", next ? "关闭座椅固件开发工作台" : "打开座椅固件开发工作台");
|
||||
trigger.title = next ? "关闭座椅固件开发工作台" : "打开座椅固件开发工作台";
|
||||
if (next) {
|
||||
state.returnFocus = document.activeElement;
|
||||
layer.hidden = false;
|
||||
layer.inert = false;
|
||||
layer.dataset.open = "true";
|
||||
setBackgroundInert(true);
|
||||
requestAnimationFrame(() => document.querySelector("#firmwareWorkbenchClose")?.focus());
|
||||
} else {
|
||||
layer.dataset.open = "false";
|
||||
layer.inert = true;
|
||||
layer.hidden = true;
|
||||
setBackgroundInert(false);
|
||||
const target = state.returnFocus instanceof HTMLElement && state.returnFocus.isConnected
|
||||
? state.returnFocus
|
||||
: trigger;
|
||||
state.returnFocus = null;
|
||||
target.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function mount() {
|
||||
const dock = document.querySelector("#v2PanelControlDock");
|
||||
if (!dock || document.querySelector("#firmwareWorkbenchToggle")) return false;
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.id = "firmwareWorkbenchToggle";
|
||||
trigger.className = "firmware-workbench-toggle v2-panel-control-button";
|
||||
trigger.type = "button";
|
||||
trigger.setAttribute("aria-controls", "firmwareWorkbenchLayer");
|
||||
trigger.setAttribute("aria-pressed", "false");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.setAttribute("aria-label", "打开座椅固件开发工作台");
|
||||
trigger.title = "打开座椅固件开发工作台";
|
||||
trigger.innerHTML = '<i data-lucide="blocks"></i><span class="visually-hidden">座椅固件开发工作台</span>';
|
||||
|
||||
const layer = document.createElement("div");
|
||||
layer.id = "firmwareWorkbenchLayer";
|
||||
layer.className = "firmware-workbench-layer";
|
||||
layer.hidden = true;
|
||||
layer.inert = true;
|
||||
layer.setAttribute("role", "dialog");
|
||||
layer.setAttribute("aria-modal", "true");
|
||||
layer.setAttribute("aria-labelledby", "firmwareWorkbenchTitle");
|
||||
layer.innerHTML = `
|
||||
<section class="firmware-workbench-page">
|
||||
<header class="firmware-workbench-header">
|
||||
<div class="firmware-workbench-heading">
|
||||
<span><i data-lucide="blocks"></i></span>
|
||||
<h2 id="firmwareWorkbenchTitle">座椅固件开发工作台</h2>
|
||||
</div>
|
||||
<button id="firmwareWorkbenchClose" type="button" aria-label="关闭座椅固件开发工作台" title="关闭"><i data-lucide="x"></i></button>
|
||||
</header>
|
||||
<iframe class="firmware-workbench-frame" src="/firmware-workbench.html?embedded=1" title="座椅固件开发工作台"></iframe>
|
||||
</section>`;
|
||||
|
||||
dock.append(trigger);
|
||||
document.body.append(layer);
|
||||
trigger.addEventListener("click", () => setOpen(!state.open));
|
||||
document.querySelector("#firmwareWorkbenchClose", layer).addEventListener("click", () => setOpen(false));
|
||||
layer.addEventListener("click", (event) => { if (event.target === layer) setOpen(false); });
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (state.open && event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!mount()) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (mount()) observer.disconnect();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
window.setTimeout(() => observer.disconnect(), 15000);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,456 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Microsoft YaHei UI", "PingFang SC", Inter, system-ui, sans-serif;
|
||||
--page: #eef3f9;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f7f9fc;
|
||||
--line: #dce4ee;
|
||||
--line-strong: #cbd7e5;
|
||||
--text: #172033;
|
||||
--muted: #68758b;
|
||||
--primary: #2869e8;
|
||||
--primary-soft: #eaf1ff;
|
||||
--success: #16845b;
|
||||
--success-soft: #e7f7f0;
|
||||
--warning: #ba7314;
|
||||
--danger: #c23f4a;
|
||||
--shadow: 0 22px 60px rgba(43, 67, 101, .14), 0 4px 14px rgba(43, 67, 101, .08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
background:
|
||||
radial-gradient(circle at 15% 8%, rgba(74, 130, 235, .11), transparent 31%),
|
||||
linear-gradient(145deg, #f7f9fc 0%, var(--page) 100%);
|
||||
}
|
||||
|
||||
button, textarea, select { font: inherit; }
|
||||
button, a, select { -webkit-tap-highlight-color: transparent; }
|
||||
button, select { cursor: pointer; }
|
||||
|
||||
.shell {
|
||||
width: min(1280px, 100%);
|
||||
height: min(780px, calc(100dvh - 32px));
|
||||
min-height: 620px;
|
||||
overflow: hidden;
|
||||
background: #f7f9fc;
|
||||
border: 1px solid rgba(205, 216, 230, .9);
|
||||
border-radius: 22px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 68px;
|
||||
padding: 0 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
background: rgba(255, 255, 255, .94);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.topbar-main, .topbar-status, .card-heading, .button-row, .log-actions,
|
||||
.server-target, .command-footer, .capability-brief { display: flex; align-items: center; }
|
||||
.topbar-main { gap: 11px; min-width: 0; }
|
||||
.topbar-status { gap: 9px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
|
||||
.back-link, .product-mark, .icon-button, .result-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
}
|
||||
.back-link, .icon-button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
color: #4d5b71;
|
||||
background: var(--surface);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover, .icon-button:hover { color: var(--primary); border-color: #a9c1ef; background: var(--primary-soft); }
|
||||
.back-link svg, .icon-button svg { width: 17px; height: 17px; }
|
||||
.product-mark { width: 36px; height: 36px; border-radius: 11px; color: white; background: linear-gradient(145deg, #3a7cf0, #1d59cd); box-shadow: 0 7px 16px rgba(40, 105, 232, .22); }
|
||||
.product-mark svg { width: 19px; }
|
||||
.eyebrow, .section-kicker { margin: 0; color: #7a88a0; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
|
||||
h1 { margin: 1px 0 0; font-size: 20px; line-height: 1.15; letter-spacing: -.02em; }
|
||||
h2 { margin: 1px 0 0; font-size: 17px; line-height: 1.2; }
|
||||
|
||||
.environment-chip, .server-state, .status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 29px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: #58677d;
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.environment-chip svg { width: 14px; }
|
||||
.server-state span { width: 7px; height: 7px; border-radius: 50%; background: #9aa7b8; }
|
||||
.server-state.online { color: var(--success); border-color: #b9e5d3; background: var(--success-soft); }
|
||||
.server-state.online span { background: var(--success); box-shadow: 0 0 0 4px rgba(22, 132, 91, .1); }
|
||||
|
||||
main {
|
||||
height: calc(100% - 68px);
|
||||
min-height: 0;
|
||||
padding: 11px;
|
||||
display: grid;
|
||||
grid-template-columns: 380px minmax(0, 1fr);
|
||||
grid-template-rows: 116px minmax(0, 1fr) 172px;
|
||||
grid-template-areas:
|
||||
"pipeline pipeline"
|
||||
"command execution"
|
||||
"software software";
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 2px 8px rgba(46, 66, 94, .045);
|
||||
}
|
||||
.card-heading { justify-content: space-between; gap: 12px; min-height: 32px; }
|
||||
.hero-grid { grid-area: command; min-width: 0; min-height: 0; }
|
||||
.hero-grid .command-card { height: 100%; }
|
||||
.pipeline-card { grid-area: pipeline; }
|
||||
.execution-grid { grid-area: execution; min-width: 0; min-height: 0; }
|
||||
.execution-grid .log-card { width: 100%; height: 100%; }
|
||||
.software-card { grid-area: software; }
|
||||
|
||||
.baseline-picker {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
align-items: center;
|
||||
column-gap: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.baseline-picker svg { grid-row: 1 / 3; width: 16px; color: var(--primary); }
|
||||
.baseline-picker span { line-height: 1; }
|
||||
.baseline-picker select {
|
||||
max-width: 148px;
|
||||
border: 0;
|
||||
padding: 1px 18px 1px 0;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.command-card { display: flex; flex-direction: column; gap: 7px; }
|
||||
.command-card > label { color: #5d6b80; font-size: 13px; font-weight: 650; }
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
flex: 1 1 70px;
|
||||
resize: none;
|
||||
padding: 9px 11px;
|
||||
color: var(--text);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
outline: none;
|
||||
}
|
||||
textarea:focus { border-color: #79a4f4; box-shadow: 0 0 0 3px rgba(40, 105, 232, .1); background: #fff; }
|
||||
.prompt-templates { display: flex; gap: 6px; overflow: hidden; }
|
||||
.prompt-templates button {
|
||||
min-width: 0;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #cbd9ef;
|
||||
border-radius: 8px;
|
||||
color: #3563a9;
|
||||
background: #f3f7ff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.capability-brief {
|
||||
min-height: 50px;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid #dbe6f6;
|
||||
border-radius: 10px;
|
||||
background: #f6f9fe;
|
||||
}
|
||||
.capability-brief > div { min-width: 0; display: grid; gap: 1px; }
|
||||
.capability-brief span { color: var(--muted); font-size: 12px; }
|
||||
.capability-brief strong { overflow: hidden; text-overflow: ellipsis; color: #344157; font-size: 13px; white-space: nowrap; }
|
||||
.capability-brief > svg { width: 17px; flex: 0 0 auto; color: #8fa7ca; }
|
||||
.capability-target { flex: 1; }
|
||||
.capability-target strong { color: var(--primary); }
|
||||
.command-footer { justify-content: flex-end; gap: 8px; }
|
||||
.button-row { gap: 6px; }
|
||||
.button {
|
||||
min-height: 33px;
|
||||
padding: 6px 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.button svg { width: 15px; }
|
||||
.button-primary { color: white; background: var(--primary); box-shadow: 0 5px 12px rgba(40, 105, 232, .18); }
|
||||
.button-secondary { color: #526178; background: #fff; border-color: var(--line-strong); }
|
||||
.button:disabled, .icon-button:disabled { cursor: not-allowed; opacity: .48; }
|
||||
|
||||
.pipeline {
|
||||
width: calc(100% - 230px);
|
||||
height: 58px;
|
||||
margin: 7px 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
list-style: none;
|
||||
}
|
||||
.pipeline-stage { position: relative; min-width: 0; padding-top: 2px; text-align: center; }
|
||||
.pipeline-stage::after { content: ""; position: absolute; top: 14px; left: calc(50% + 15px); width: calc(100% - 30px); height: 2px; background: #dce4ee; }
|
||||
.pipeline-stage:last-child::after { display: none; }
|
||||
.stage-node { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto 1px; display: grid; place-items: center; border: 2px solid #d4deea; border-radius: 50%; color: #7b8aa0; background: #fff; }
|
||||
.stage-node svg { width: 14px; }
|
||||
.stage-name { display: block; overflow: hidden; text-overflow: ellipsis; font-size: 13px; line-height: 1.15; white-space: nowrap; }
|
||||
.stage-state { display: block; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font-size: 12px; line-height: 1.1; white-space: nowrap; }
|
||||
.pipeline-stage[data-status="running"] .stage-node { color: var(--primary); border-color: var(--primary); background: var(--primary-soft); animation: pulse 1.4s infinite; }
|
||||
.pipeline-stage[data-status="success"] .stage-node { color: var(--success); border-color: #65bd9e; background: var(--success-soft); }
|
||||
.pipeline-stage[data-status="failed"] .stage-node, .pipeline-stage[data-status="blocked"] .stage-node { color: var(--danger); border-color: #e39aa1; background: #fff0f1; }
|
||||
.pipeline-stage[data-status="canceled"] .stage-node { color: #7b8798; background: #eef1f5; }
|
||||
.pipeline-stage[data-status="pending_confirmation"] .stage-node { color: var(--warning); border-color: #ecc080; background: #fff7e9; }
|
||||
.pipeline-stage[data-status="success"]::after { background: #8bcdb5; }
|
||||
.release-result {
|
||||
position: absolute;
|
||||
top: 43px;
|
||||
right: 14px;
|
||||
width: 210px;
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
.result-icon { width: 32px; height: 32px; flex: 0 0 auto; color: #748399; border-radius: 9px; background: #e9eef5; }
|
||||
.result-icon svg { width: 17px; }
|
||||
.release-result div { min-width: 0; display: grid; }
|
||||
.release-result small { color: var(--muted); font-size: 12px; }
|
||||
.release-result strong { overflow: hidden; text-overflow: ellipsis; font-size: 13px; white-space: nowrap; }
|
||||
.release-result[data-state="success"] { border-color: #b9e5d3; background: var(--success-soft); }
|
||||
.release-result[data-state="success"] .result-icon { color: var(--success); background: #d2f0e3; }
|
||||
.release-result[data-state="failed"] { border-color: #efc5c9; background: #fff2f3; }
|
||||
.status-legend { display: none; }
|
||||
|
||||
.log-card { display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 7px; }
|
||||
.log-actions { gap: 6px; }
|
||||
.log-actions .icon-button { width: 31px; height: 31px; }
|
||||
.status-badge { min-height: 25px; padding: 3px 9px; }
|
||||
.status-badge[data-status="running"] { color: var(--primary); border-color: #b9cff7; background: var(--primary-soft); }
|
||||
.status-badge[data-status="success"], .status-badge[data-status="published"] { color: var(--success); border-color: #b9e5d3; background: var(--success-soft); }
|
||||
.status-badge[data-status="failed"], .status-badge[data-status="blocked"] { color: var(--danger); border-color: #efc5c9; background: #fff2f3; }
|
||||
.status-badge[data-status="ready"], .status-badge[data-status="pending_confirmation"] { color: var(--warning); border-color: #efd3a8; background: #fff7e9; }
|
||||
.metrics { margin: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
|
||||
.metrics div { min-width: 0; padding: 5px 7px; border-radius: 8px; background: var(--surface-soft); border: 1px solid #edf1f6; }
|
||||
.metrics dt { color: var(--muted); font-size: 12px; }
|
||||
.metrics dd { margin: 1px 0 0; overflow: hidden; text-overflow: ellipsis; font-size: 13px; font-weight: 750; white-space: nowrap; }
|
||||
.log-workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(220px, .9fr); gap: 8px; }
|
||||
.log-stream, .log-details { min-height: 0; overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: #f9fbfd; }
|
||||
.log-stream { padding: 7px 9px; scrollbar-width: thin; }
|
||||
.empty-state { height: 100%; min-height: 110px; display: grid; place-content: center; justify-items: center; gap: 4px; color: var(--muted); text-align: center; }
|
||||
.empty-state svg { width: 24px; color: #93a3ba; }
|
||||
.empty-state strong { color: #4a596f; font-size: 13px; }
|
||||
.empty-state span { font-size: 12px; }
|
||||
.log-entry { display: grid; grid-template-columns: 58px 9px minmax(0, 1fr); gap: 7px; padding: 5px 0; border-bottom: 1px solid #e9eef5; }
|
||||
.log-entry:last-child { border-bottom: 0; }
|
||||
.log-time { color: #7b889b; font: 12px/1.4 ui-monospace, Consolas, monospace; }
|
||||
.log-dot { width: 7px; height: 7px; margin-top: 5px; border-radius: 50%; background: #91a0b4; }
|
||||
.log-entry[data-level="success"] .log-dot { background: var(--success); }
|
||||
.log-entry[data-level="error"] .log-dot { background: var(--danger); }
|
||||
.log-entry[data-level="warning"] .log-dot { background: var(--warning); }
|
||||
.log-copy { min-width: 0; }
|
||||
.log-copy strong { font-size: 13px; }
|
||||
.log-copy p { margin: 1px 0 0; color: #5b687b; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.log-details { display: flex; flex-direction: column; }
|
||||
.inspector-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--line); background: #f2f5f9; }
|
||||
.inspector-tabs button { padding: 7px 4px; border: 0; border-bottom: 2px solid transparent; color: #68768b; background: transparent; font-size: 12px; font-weight: 700; }
|
||||
.inspector-tabs button.active { color: var(--primary); border-bottom-color: var(--primary); background: #fff; }
|
||||
.inspector-content { min-height: 0; flex: 1; overflow: auto; padding: 7px; }
|
||||
.empty-inline, .empty-table { padding: 18px; color: var(--muted); text-align: center; font-size: 12px; }
|
||||
.change-item, .test-item { display: flex; gap: 7px; padding: 6px; border-bottom: 1px solid #e8edf4; }
|
||||
.change-type, .test-mark { flex: 0 0 auto; height: 21px; padding: 2px 5px; border-radius: 5px; color: var(--primary); background: var(--primary-soft); font-size: 12px; }
|
||||
.test-mark { width: 21px; display: grid; place-items: center; color: var(--success); background: var(--success-soft); }
|
||||
.test-mark svg { width: 12px; }
|
||||
.change-copy, .test-copy { min-width: 0; display: grid; }
|
||||
.change-copy strong, .test-copy strong { font-size: 12px; overflow-wrap: anywhere; }
|
||||
.change-copy span, .test-copy span { color: var(--muted); font-size: 12px; }
|
||||
.build-output, .code-block { margin: 0; color: #26364d; font: 12px/1.55 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
.software-card { display: grid; grid-template-rows: 33px minmax(0, 1fr); gap: 5px; }
|
||||
.server-target { gap: 6px; color: var(--muted); font-size: 12px; }
|
||||
.server-target svg { width: 15px; color: var(--primary); }
|
||||
.server-target strong { color: #3b4a61; }
|
||||
.table-wrap { min-height: 0; overflow: auto; border: 1px solid var(--line); border-radius: 9px; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; }
|
||||
th { position: sticky; top: 0; z-index: 1; padding: 6px 7px; color: #617086; background: #f2f5f9; text-align: left; font-weight: 750; white-space: nowrap; }
|
||||
td { padding: 6px 7px; border-top: 1px solid #e8edf4; color: #4d5a6e; vertical-align: middle; }
|
||||
th:nth-child(1) { width: 14%; } th:nth-child(2) { width: 13%; } th:nth-child(3) { width: 11%; }
|
||||
th:nth-child(4) { width: 28%; } th:nth-child(5) { width: 8%; } th:nth-child(6) { width: 26%; }
|
||||
.software-name, .file-cell { min-width: 0; display: grid; }
|
||||
.software-name strong, .file-cell strong { overflow: hidden; text-overflow: ellipsis; color: #26354b; white-space: nowrap; }
|
||||
.software-name span, .file-cell span { color: var(--muted); }
|
||||
.file-cell > span { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cap-tags { display: flex; gap: 3px; flex-wrap: wrap; }
|
||||
.cap-tags span { padding: 2px 5px; color: #3563a9; background: #eef4ff; border-radius: 5px; white-space: nowrap; }
|
||||
.hash-value { display: inline; font-family: ui-monospace, Consolas, monospace; white-space: nowrap; }
|
||||
.table-actions { display: flex; align-items: center; gap: 3px; flex-wrap: nowrap; }
|
||||
.table-actions button, .table-actions a { min-width: 27px; height: 26px; padding: 3px 5px; display: inline-grid; place-items: center; border: 0; border-radius: 5px; color: #3563a9; background: #edf3ff; font-size: 12px; text-decoration: none; white-space: nowrap; }
|
||||
.table-actions svg { width: 13px; height: 13px; }
|
||||
.table-actions .publish-action { color: white; background: var(--primary); }
|
||||
.table-actions .delete-action { color: var(--danger); background: #fff0f1; }
|
||||
.baseline-protection { height: 26px; padding: 3px 6px; display: inline-flex; align-items: center; gap: 3px; color: var(--success); background: var(--success-soft); border-radius: 5px; font-size: 12px; white-space: nowrap; }
|
||||
.baseline-protection svg { width: 13px; }
|
||||
.status-badge[data-status="baseline"] { color: var(--success); border-color: #b9e5d3; background: var(--success-soft); }
|
||||
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 24px; background: rgba(31, 43, 61, .34); backdrop-filter: blur(4px); }
|
||||
.modal-backdrop[hidden] { display: none; }
|
||||
.modal { width: min(780px, 100%); max-height: min(680px, calc(100dvh - 48px)); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; background: #fff; border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow); }
|
||||
.modal header, .modal footer { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); }
|
||||
.modal footer { justify-content: flex-end; border-top: 1px solid var(--line); border-bottom: 0; }
|
||||
.modal-body { overflow: auto; padding: 16px; }
|
||||
.detail-grid, .diff-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.detail-field, .diff-panel { min-width: 0; padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-soft); }
|
||||
.detail-field span { display: block; color: var(--muted); font-size: 12px; }
|
||||
.detail-field strong { display: block; margin-top: 3px; overflow-wrap: anywhere; }
|
||||
.diff-panel h3 { margin: 0 0 8px; font-size: 14px; }
|
||||
.diff-panel p { margin: 5px 0; color: #58677c; }
|
||||
.diff-panel.added { border-color: #b9e5d3; background: var(--success-soft); }
|
||||
.toast-region { position: fixed; z-index: 80; right: 22px; bottom: 22px; display: grid; gap: 8px; }
|
||||
.toast { max-width: 390px; padding: 10px 13px; color: #fff; background: #344258; border-radius: 9px; box-shadow: 0 10px 28px rgba(31, 43, 61, .22); font-size: 13px; }
|
||||
.toast.success { background: var(--success); }
|
||||
.toast.error { background: var(--danger); }
|
||||
|
||||
@keyframes pulse { 50% { box-shadow: 0 0 0 5px rgba(40, 105, 232, .09); } }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
body { padding: 8px; }
|
||||
.shell { width: 100%; height: calc(100dvh - 16px); min-height: 560px; border-radius: 16px; }
|
||||
.topbar { height: 62px; padding: 0 14px; }
|
||||
.topbar-status .dashboard-chip, .environment-chip:last-child { display: none; }
|
||||
main { height: calc(100% - 62px); grid-template-columns: 330px minmax(0, 1fr); grid-template-rows: 108px minmax(0, 1fr) 164px; padding: 8px; gap: 8px; }
|
||||
.card { padding: 10px; }
|
||||
.pipeline { width: calc(100% - 190px); }
|
||||
.release-result { width: 176px; right: 10px; }
|
||||
.log-workspace { grid-template-columns: 1fr; }
|
||||
.log-details { display: none; }
|
||||
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
body { display: block; padding: 0; }
|
||||
.shell { height: 100dvh; min-height: 0; border: 0; border-radius: 0; }
|
||||
.topbar { height: 58px; }
|
||||
h1 { font-size: 17px; }
|
||||
.eyebrow, .topbar-status { display: none; }
|
||||
main { height: calc(100% - 58px); overflow: auto; grid-template-columns: 1fr; grid-template-rows: 104px 360px 330px 190px; grid-template-areas: "pipeline" "command" "execution" "software"; }
|
||||
.execution-grid { display: block; }
|
||||
.execution-grid .log-card { min-height: 330px; }
|
||||
.pipeline { width: 100%; }
|
||||
.pipeline-card { overflow-x: auto; }
|
||||
.pipeline-card .pipeline { width: 680px; }
|
||||
.release-result { display: none; }
|
||||
.stage-name { font-size: 11px; }
|
||||
.stage-state { display: none; }
|
||||
.command-card { gap: 5px; }
|
||||
.command-footer p { display: none; }
|
||||
.command-footer { justify-content: flex-end; }
|
||||
.prompt-templates button { flex: 1; }
|
||||
th:nth-child(2), td:nth-child(2), th:nth-child(3), td:nth-child(3) { display: none; }
|
||||
th:nth-child(1), th:nth-child(4), th:nth-child(5), th:nth-child(6) { width: auto; }
|
||||
}
|
||||
|
||||
body.embedded {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
body.embedded .shell {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
body.embedded .topbar { display: none; }
|
||||
body.embedded main {
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
grid-template-columns: 350px minmax(0, 1fr);
|
||||
grid-template-rows: 82px minmax(0, 1fr) 204px;
|
||||
gap: 8px;
|
||||
}
|
||||
body.embedded .card { padding: 8px 10px; border-radius: 12px; }
|
||||
body.embedded .card-heading { min-height: 27px; }
|
||||
body.embedded .command-card { gap: 5px; }
|
||||
body.embedded textarea { min-height: 48px; padding: 7px 9px; }
|
||||
body.embedded .prompt-templates button { padding: 4px 7px; }
|
||||
body.embedded .capability-brief { min-height: 44px; padding: 5px 8px; }
|
||||
body.embedded .pipeline { height: 48px; margin: 4px 0 0; }
|
||||
body.embedded .stage-node { width: 24px; height: 24px; }
|
||||
body.embedded .pipeline-stage::after { top: 12px; }
|
||||
body.embedded .stage-node svg { width: 12px; }
|
||||
body.embedded .stage-name { font-size: 12px; }
|
||||
body.embedded .stage-state { font-size: 11px; }
|
||||
body.embedded .release-result { top: 18px; min-height: 44px; padding: 5px 8px; }
|
||||
body.embedded .result-icon { width: 28px; height: 28px; }
|
||||
body.embedded .log-card { gap: 5px; }
|
||||
body.embedded .metrics div { padding: 3px 6px; }
|
||||
body.embedded .software-card { grid-template-rows: 27px minmax(0, 1fr); gap: 3px; }
|
||||
body.embedded th { padding: 4px 6px; }
|
||||
body.embedded table { font-size: 12.5px; }
|
||||
body.embedded td { padding: 3px 6px; }
|
||||
body.embedded .software-name strong,
|
||||
body.embedded .file-cell strong { line-height: 1.2; }
|
||||
body.embedded .software-name span,
|
||||
body.embedded .file-cell span { line-height: 1.15; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
body.embedded main {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 96px 350px 320px 210px;
|
||||
grid-template-areas: "pipeline" "command" "execution" "software";
|
||||
}
|
||||
body.embedded .pipeline-card { overflow-x: auto; }
|
||||
body.embedded .pipeline-card .pipeline { width: 680px; }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#f3f6fb" />
|
||||
<title>座椅固件开发工作台 · 511 工程</title>
|
||||
<link rel="icon" href="/icons/app-icon-192.png" />
|
||||
<link rel="stylesheet" href="/firmware-workbench.css?v=5" />
|
||||
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
|
||||
<script src="/firmware-workbench.js?v=3" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<div class="topbar-main">
|
||||
<a class="back-link" href="/" aria-label="返回智能座舱">
|
||||
<i data-lucide="arrow-left"></i>
|
||||
</a>
|
||||
<div class="product-mark" aria-hidden="true"><i data-lucide="blocks"></i></div>
|
||||
<h1>座椅固件开发工作台</h1>
|
||||
</div>
|
||||
<div class="topbar-status">
|
||||
<span class="server-state" id="serverState"><span></span>正在连接发布服务</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero-grid" aria-label="固件开发任务">
|
||||
<article class="card command-card">
|
||||
<div class="card-heading">
|
||||
<div>
|
||||
<h2>开发指令</h2>
|
||||
</div>
|
||||
<label class="baseline-picker" for="baselineSelect">
|
||||
<i data-lucide="git-branch"></i>
|
||||
<span>固件基线</span>
|
||||
<select id="baselineSelect" aria-label="选择固件基线">
|
||||
<option value="time_only">时间控制</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label for="instructionInput">描述需要扩展的副驾座椅能力</label>
|
||||
<textarea id="instructionInput" rows="5" maxlength="2000">将副驾座椅固件从仅时间控制扩展为时间+位置参数控制,并增加座椅通风和座椅加热功能;完成编译、测试验证、固件打包,并发布到升级服务器。</textarea>
|
||||
<div class="prompt-templates" aria-label="常用开发指令">
|
||||
<button type="button" data-prompt="增加支持座椅位置参数控制的能力">+ 位置控制</button>
|
||||
<button type="button" data-prompt="增加座椅通风功能">+ 座椅通风</button>
|
||||
<button type="button" data-prompt="增加座椅加热功能">+ 座椅加热</button>
|
||||
</div>
|
||||
<div class="capability-brief" aria-label="固件能力升级范围">
|
||||
<div><span>当前基线</span><strong id="baselineCapabilities">时间控制</strong></div>
|
||||
<i data-lucide="chevrons-right"></i>
|
||||
<div class="capability-target"><span>目标固件能力</span><strong id="targetCapabilities">时间控制+位置控制+座椅通风+座椅加热</strong></div>
|
||||
</div>
|
||||
<div class="command-footer">
|
||||
<div class="button-row">
|
||||
<button class="button button-secondary" id="cancelTaskButton" type="button" disabled>
|
||||
<i data-lucide="square"></i>取消任务
|
||||
</button>
|
||||
<button class="button button-primary" id="startTaskButton" type="button">
|
||||
<i data-lucide="play"></i>开始生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card pipeline-card" aria-label="固件交付流水线">
|
||||
<ol class="pipeline" id="pipeline" aria-label="固件交付阶段"></ol>
|
||||
<div class="release-result" id="releaseResult" data-state="idle">
|
||||
<span class="result-icon"><i data-lucide="cloud-upload"></i></span>
|
||||
<div><small>当前交付状态</small><strong>尚未开始</strong></div>
|
||||
</div>
|
||||
<div class="status-legend" aria-label="阶段状态图例">
|
||||
<span data-status="waiting">等待</span><span data-status="running">运行中</span>
|
||||
<span data-status="pending_confirmation">待确认</span><span data-status="success">成功</span>
|
||||
<span data-status="failed">失败</span><span data-status="canceled">取消</span>
|
||||
<span data-status="blocked">阻塞</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="execution-grid">
|
||||
<article class="card log-card" aria-labelledby="logTitle">
|
||||
<div class="card-heading">
|
||||
<div>
|
||||
<h2 id="logTitle">任务执行日志</h2>
|
||||
</div>
|
||||
<div class="log-actions">
|
||||
<span class="status-badge" id="taskStatusBadge" data-status="waiting">等待</span>
|
||||
<button class="icon-button" id="retryTaskButton" type="button" title="重试失败任务" disabled><i data-lucide="refresh-cw"></i></button>
|
||||
<button class="icon-button" id="scrollLogButton" type="button" title="滚动到最新日志"><i data-lucide="arrow-down-to-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="metrics" id="taskMetrics">
|
||||
<div><dt>开始时间</dt><dd>—</dd></div><div><dt>结束时间</dt><dd>—</dd></div>
|
||||
<div><dt>实际耗时</dt><dd>—</dd></div><div><dt>文件变更</dt><dd>—</dd></div>
|
||||
</dl>
|
||||
<div class="log-workspace">
|
||||
<div class="log-stream" id="logStream" role="log" aria-live="polite">
|
||||
<div class="empty-state"><i data-lucide="terminal-square"></i><strong>暂无执行记录</strong><span>创建开发任务后,这里将实时显示各阶段输出。</span></div>
|
||||
</div>
|
||||
<aside class="log-details" aria-label="日志附属详情">
|
||||
<div class="inspector-tabs" role="tablist">
|
||||
<button class="active" type="button" role="tab" data-inspector-tab="changes">文件变更</button>
|
||||
<button type="button" role="tab" data-inspector-tab="build">编译输出</button>
|
||||
<button type="button" role="tab" data-inspector-tab="tests">测试验证</button>
|
||||
</div>
|
||||
<div class="inspector-content" id="inspectorContent">
|
||||
<div class="empty-inline">暂无任务数据</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card software-card" aria-labelledby="softwareTitle">
|
||||
<div class="card-heading software-heading">
|
||||
<div>
|
||||
<h2 id="softwareTitle">固件版本与软件产物</h2>
|
||||
</div>
|
||||
<div class="server-target"><i data-lucide="server"></i><span>发布目标</span><strong id="otaServerName">车机升级服务器</strong></div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>软件 / 版本</th><th>能力</th><th>构建时间</th><th>固件文件 / 校验</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody id="artifactTableBody"><tr><td colspan="6"><div class="empty-table">尚未生成固件产物</div></td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="detailModal" hidden>
|
||||
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<header><h2 id="modalTitle">固件详情</h2><button class="icon-button" id="closeModalButton" type="button" aria-label="关闭"><i data-lucide="x"></i></button></header>
|
||||
<div class="modal-body" id="modalBody"></div>
|
||||
<footer><button class="button button-secondary" id="modalCloseButton" type="button">关闭</button></footer>
|
||||
</section>
|
||||
</div>
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,449 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
if (window.self !== window.top || new URLSearchParams(window.location.search).has("embedded")) {
|
||||
document.body.classList.add("embedded");
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
waiting: "等待",
|
||||
running: "运行中",
|
||||
pending_confirmation: "待确认",
|
||||
success: "成功",
|
||||
failed: "失败",
|
||||
canceled: "取消",
|
||||
blocked: "阻塞",
|
||||
published: "已发布",
|
||||
ready: "待发布",
|
||||
baseline: "基线固件",
|
||||
};
|
||||
const STAGE_ICONS = {
|
||||
instruction: "file-input",
|
||||
requirements: "list-checks",
|
||||
baseline: "git-compare-arrows",
|
||||
codegen: "code-2",
|
||||
build: "hammer",
|
||||
test: "shield-check",
|
||||
package: "package-check",
|
||||
publish: "cloud-upload",
|
||||
};
|
||||
const ACTIVE_STATES = new Set(["waiting", "running", "pending_confirmation"]);
|
||||
const CAPABILITY_ORDER = ["时间控制", "位置控制", "座椅通风", "座椅加热"];
|
||||
const FALLBACK_BASELINES = {
|
||||
time_only: ["时间控制"],
|
||||
};
|
||||
const FALLBACK_PIPELINE = [
|
||||
["instruction", "指令输入"], ["requirements", "需求解析"],
|
||||
["baseline", "基线检查"], ["codegen", "代码生成"],
|
||||
["build", "编译构建"], ["test", "测试验证"],
|
||||
["package", "固件打包"], ["publish", "发布服务器"],
|
||||
].map(([id, name]) => ({ id, name, status: "waiting", summary: "等待执行" }));
|
||||
|
||||
const ui = {
|
||||
instruction: document.querySelector("#instructionInput"),
|
||||
baseline: document.querySelector("#baselineSelect"),
|
||||
baselineCapabilities: document.querySelector("#baselineCapabilities"),
|
||||
targetCapabilities: document.querySelector("#targetCapabilities"),
|
||||
start: document.querySelector("#startTaskButton"),
|
||||
cancel: document.querySelector("#cancelTaskButton"),
|
||||
retry: document.querySelector("#retryTaskButton"),
|
||||
server: document.querySelector("#serverState"),
|
||||
serverName: document.querySelector("#otaServerName"),
|
||||
release: document.querySelector("#releaseResult"),
|
||||
pipeline: document.querySelector("#pipeline"),
|
||||
logs: document.querySelector("#logStream"),
|
||||
scrollLogs: document.querySelector("#scrollLogButton"),
|
||||
status: document.querySelector("#taskStatusBadge"),
|
||||
metrics: document.querySelector("#taskMetrics"),
|
||||
inspector: document.querySelector("#inspectorContent"),
|
||||
artifacts: document.querySelector("#artifactTableBody"),
|
||||
modal: document.querySelector("#detailModal"),
|
||||
modalTitle: document.querySelector("#modalTitle"),
|
||||
modalBody: document.querySelector("#modalBody"),
|
||||
modalClose: document.querySelector("#closeModalButton"),
|
||||
modalCloseFooter: document.querySelector("#modalCloseButton"),
|
||||
toasts: document.querySelector("#toastRegion"),
|
||||
};
|
||||
const model = {
|
||||
snapshot: { tasks: [], artifacts: [], releases: [], pipeline: FALLBACK_PIPELINE },
|
||||
selectedTaskId: null,
|
||||
inspectorTab: "changes",
|
||||
pending: false,
|
||||
pollTimer: null,
|
||||
lastLogCount: 0,
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||||
...options,
|
||||
});
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const payload = contentType.includes("application/json") ? await response.json() : null;
|
||||
if (!response.ok) throw new Error(payload?.message || `请求失败(${response.status})`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function selectedTask() {
|
||||
return model.snapshot.tasks.find((task) => task.id === model.selectedTaskId)
|
||||
|| model.snapshot.tasks[0]
|
||||
|| null;
|
||||
}
|
||||
|
||||
function baselineCapabilities() {
|
||||
const baseline = model.snapshot.baselines?.find((item) => item.id === ui.baseline.value);
|
||||
return baseline?.capabilities || FALLBACK_BASELINES[ui.baseline.value] || FALLBACK_BASELINES.time_only;
|
||||
}
|
||||
|
||||
function parseInstructionCapabilities(instruction) {
|
||||
const lowered = String(instruction || "").toLowerCase();
|
||||
const aliases = {
|
||||
"时间控制": ["时间", "定时", "timed"],
|
||||
"位置控制": ["位置", "position"],
|
||||
"座椅通风": ["通风", "vent"],
|
||||
"座椅加热": ["加热", "heater", "heat"],
|
||||
};
|
||||
return CAPABILITY_ORDER.filter((capability) => aliases[capability].some((alias) => lowered.includes(alias)));
|
||||
}
|
||||
|
||||
function updateCapabilityPreview(task = null) {
|
||||
const baseline = baselineCapabilities();
|
||||
const taskMatchesDraft = task
|
||||
&& task.instruction === ui.instruction.value.trim()
|
||||
&& (task.baseline_id || "time_only") === ui.baseline.value;
|
||||
const target = taskMatchesDraft
|
||||
? task.target_capabilities
|
||||
: CAPABILITY_ORDER.filter((item) => baseline.includes(item) || parseInstructionCapabilities(ui.instruction.value).includes(item));
|
||||
ui.baselineCapabilities.textContent = baseline.join("+");
|
||||
ui.targetCapabilities.textContent = (target?.length ? target : baseline).join("+");
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date).replaceAll("/", "-");
|
||||
}
|
||||
|
||||
function formatClock(value) {
|
||||
if (!value) return "--:--:--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--:--:--";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds) {
|
||||
if (milliseconds === null || milliseconds === undefined) return "—";
|
||||
if (milliseconds < 1000) return `${milliseconds} 毫秒`;
|
||||
const seconds = milliseconds / 1000;
|
||||
return seconds < 60 ? `${seconds.toFixed(seconds < 10 ? 1 : 0)} 秒` : `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`;
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (value < 1024) return `${value} 字节`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} 千字节`;
|
||||
return `${(value / 1024 / 1024).toFixed(2)} 兆字节`;
|
||||
}
|
||||
|
||||
function toast(message, type = "info") {
|
||||
const element = document.createElement("div");
|
||||
element.className = `toast ${type}`;
|
||||
element.textContent = message;
|
||||
ui.toasts.append(element);
|
||||
window.setTimeout(() => element.remove(), 4200);
|
||||
}
|
||||
|
||||
function refreshIcons() {
|
||||
if (window.lucide) window.lucide.createIcons({ attrs: { "stroke-width": 1.8 } });
|
||||
}
|
||||
|
||||
function renderPipeline(task) {
|
||||
const stages = task?.stages || model.snapshot.pipeline?.map((stage) => ({ ...stage, status: "waiting" })) || FALLBACK_PIPELINE;
|
||||
ui.pipeline.innerHTML = stages.map((stage) => `
|
||||
<li class="pipeline-stage" data-status="${escapeHtml(stage.status || "waiting")}" title="${escapeHtml(stage.summary || "")}">
|
||||
<span class="stage-node"><i data-lucide="${STAGE_ICONS[stage.id] || "circle"}"></i></span>
|
||||
<strong class="stage-name">${escapeHtml(stage.name)}</strong>
|
||||
<span class="stage-state">${escapeHtml(STATUS_LABELS[stage.status] || stage.status || "等待")}${stage.duration_ms !== null && stage.duration_ms !== undefined ? ` · ${escapeHtml(formatDuration(stage.duration_ms))}` : ""}</span>
|
||||
</li>`).join("");
|
||||
}
|
||||
|
||||
function renderRelease(task) {
|
||||
let icon = "cloud-upload";
|
||||
let label = "尚未开始";
|
||||
let state = "idle";
|
||||
if (task) {
|
||||
label = task.message || STATUS_LABELS[task.status] || task.status;
|
||||
state = task.status;
|
||||
if (task.status === "success") { icon = "circle-check-big"; state = "success"; }
|
||||
else if (task.status === "running") icon = "loader-circle";
|
||||
else if (task.status === "failed") icon = "circle-x";
|
||||
else if (task.status === "canceled") icon = "ban";
|
||||
}
|
||||
ui.release.dataset.state = state;
|
||||
ui.release.innerHTML = `<span class="result-icon"><i data-lucide="${icon}"></i></span><div><small>当前交付状态</small><strong>${escapeHtml(label)}</strong></div>`;
|
||||
}
|
||||
|
||||
function renderLogs(task) {
|
||||
const logs = task?.logs || [];
|
||||
if (!logs.length) {
|
||||
ui.logs.innerHTML = `<div class="empty-state"><i data-lucide="terminal-square"></i><strong>暂无执行记录</strong><span>创建开发任务后,这里将实时显示各阶段输出。</span></div>`;
|
||||
model.lastLogCount = 0;
|
||||
return;
|
||||
}
|
||||
const nearBottom = ui.logs.scrollHeight - ui.logs.scrollTop - ui.logs.clientHeight < 80;
|
||||
ui.logs.innerHTML = logs.map((entry) => `
|
||||
<article class="log-entry" data-level="${escapeHtml(entry.level)}">
|
||||
<time class="log-time">${escapeHtml(formatClock(entry.time))}</time>
|
||||
<span class="log-dot"></span>
|
||||
<div class="log-copy"><strong>${escapeHtml(entry.title)}</strong><p>${escapeHtml(entry.message)}</p></div>
|
||||
</article>`).join("");
|
||||
if (nearBottom || logs.length > model.lastLogCount) ui.logs.scrollTop = ui.logs.scrollHeight;
|
||||
model.lastLogCount = logs.length;
|
||||
}
|
||||
|
||||
function renderInspector(task) {
|
||||
const status = task?.status || "waiting";
|
||||
ui.status.dataset.status = status;
|
||||
ui.status.textContent = STATUS_LABELS[status] || status;
|
||||
const fileCount = task?.file_changes?.length || 0;
|
||||
ui.metrics.innerHTML = `
|
||||
<div><dt>开始时间</dt><dd>${escapeHtml(formatDate(task?.started_at))}</dd></div>
|
||||
<div><dt>结束时间</dt><dd>${escapeHtml(formatDate(task?.finished_at))}</dd></div>
|
||||
<div><dt>实际耗时</dt><dd>${escapeHtml(formatDuration(task?.duration_ms))}</dd></div>
|
||||
<div><dt>文件变更</dt><dd>${fileCount ? `${fileCount} 个文件` : "—"}</dd></div>`;
|
||||
document.querySelectorAll("[data-inspector-tab]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.inspectorTab === model.inspectorTab);
|
||||
});
|
||||
if (!task) {
|
||||
ui.inspector.innerHTML = `<div class="empty-inline">暂无任务数据</div>`;
|
||||
return;
|
||||
}
|
||||
if (model.inspectorTab === "build") {
|
||||
ui.inspector.innerHTML = task.build_output?.length
|
||||
? `<pre class="build-output">${escapeHtml(task.build_output.join("\n"))}</pre>`
|
||||
: `<div class="empty-inline">尚无编译输出</div>`;
|
||||
return;
|
||||
}
|
||||
if (model.inspectorTab === "tests") {
|
||||
ui.inspector.innerHTML = task.test_results?.length
|
||||
? task.test_results.map((test) => `<div class="test-item"><span class="test-mark"><i data-lucide="check"></i></span><div class="test-copy"><strong>${escapeHtml(test.name)}</strong><span>${escapeHtml(test.summary)}</span></div></div>`).join("")
|
||||
: `<div class="empty-inline">测试尚未执行</div>`;
|
||||
return;
|
||||
}
|
||||
ui.inspector.innerHTML = task.file_changes?.length
|
||||
? task.file_changes.map((change) => `<div class="change-item"><span class="change-type">${change.type === "added" ? "新增" : "修改"}</span><div class="change-copy"><strong>${escapeHtml(change.path)}</strong><span>${escapeHtml(change.summary)}</span></div></div>`).join("")
|
||||
: `<div class="empty-inline">尚无文件变更</div>`;
|
||||
}
|
||||
|
||||
function renderArtifacts() {
|
||||
const artifacts = model.snapshot.artifacts || [];
|
||||
if (!artifacts.length) {
|
||||
ui.artifacts.innerHTML = `<tr><td colspan="6"><div class="empty-table">尚未生成固件产物</div></td></tr>`;
|
||||
return;
|
||||
}
|
||||
ui.artifacts.innerHTML = artifacts.map((artifact) => `
|
||||
<tr>
|
||||
<td><div class="software-name"><strong>${escapeHtml(artifact.software_name)}</strong><span>${escapeHtml(artifact.version)}</span></div></td>
|
||||
<td><div class="cap-tags">${(artifact.capabilities || []).map((capability) => `<span>${escapeHtml(capability)}</span>`).join("")}</div></td>
|
||||
<td>${escapeHtml(formatDate(artifact.build_time))}</td>
|
||||
<td><div class="file-cell"><strong>${escapeHtml(artifact.firmware_filename)}</strong><span>${escapeHtml(formatSize(artifact.size_bytes))} · <span class="hash-value" title="${escapeHtml(artifact.sha256)}">${escapeHtml(artifact.sha256)}</span></span></div></td>
|
||||
<td><span class="status-badge" data-status="${escapeHtml(artifact.status)}">${escapeHtml(STATUS_LABELS[artifact.status] || artifact.status)}</span></td>
|
||||
<td><div class="table-actions">
|
||||
<button type="button" title="查看详情" data-artifact-action="detail" data-artifact-id="${artifact.id}">详情</button>
|
||||
<button type="button" title="查看能力差异" data-artifact-action="capabilities" data-artifact-id="${artifact.id}">能力</button>
|
||||
<button type="button" title="查看代码差异" data-artifact-action="code" data-artifact-id="${artifact.id}">代码</button>
|
||||
<button type="button" title="查看验证记录" data-artifact-action="validation" data-artifact-id="${artifact.id}">验证</button>
|
||||
<a href="${escapeHtml(artifact.download_url)}" title="下载固件" aria-label="下载固件" download><i data-lucide="download"></i></a>
|
||||
<button class="publish-action" type="button" title="发布到服务器" data-artifact-action="publish" data-artifact-id="${artifact.id}"><i data-lucide="cloud-upload"></i></button>
|
||||
${artifact.is_baseline ? `<span class="baseline-protection" title="时间控制基线固件受保护"><i data-lucide="shield-check"></i>受保护</span>` : `<button class="delete-action" type="button" title="删除固件产物" data-artifact-action="delete" data-artifact-id="${artifact.id}"><i data-lucide="trash-2"></i></button>`}
|
||||
</div></td>
|
||||
</tr>`).join("");
|
||||
}
|
||||
|
||||
function render() {
|
||||
const task = selectedTask();
|
||||
if (task) model.selectedTaskId = task.id;
|
||||
renderPipeline(task);
|
||||
renderRelease(task);
|
||||
renderLogs(task);
|
||||
renderInspector(task);
|
||||
renderArtifacts();
|
||||
updateCapabilityPreview(task);
|
||||
const active = task && ACTIVE_STATES.has(task.status);
|
||||
ui.start.disabled = model.pending || Boolean(active);
|
||||
ui.cancel.disabled = model.pending || !active;
|
||||
ui.retry.disabled = model.pending || !task || !["failed", "canceled", "blocked"].includes(task.status);
|
||||
ui.start.innerHTML = model.pending ? `<i data-lucide="loader-circle"></i>正在提交` : `<i data-lucide="play"></i>开始生成`;
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
async function refreshState({ quiet = false } = {}) {
|
||||
try {
|
||||
model.snapshot = await api("/api/firmware-workbench/state");
|
||||
if (!model.selectedTaskId && model.snapshot.tasks?.length) model.selectedTaskId = model.snapshot.tasks[0].id;
|
||||
ui.server.classList.add("online");
|
||||
ui.server.innerHTML = `<span></span>发布服务已连接`;
|
||||
ui.serverName.textContent = model.snapshot.ota_server?.name || "车机升级服务器";
|
||||
render();
|
||||
} catch (error) {
|
||||
ui.server.classList.remove("online");
|
||||
ui.server.innerHTML = `<span></span>发布服务连接失败`;
|
||||
if (!quiet) toast(error.message, "error");
|
||||
} finally {
|
||||
schedulePoll();
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePoll() {
|
||||
window.clearTimeout(model.pollTimer);
|
||||
const active = model.snapshot.tasks?.some((task) => ACTIVE_STATES.has(task.status));
|
||||
model.pollTimer = window.setTimeout(() => refreshState({ quiet: true }), active ? 700 : 5000);
|
||||
}
|
||||
|
||||
async function startTask() {
|
||||
const instruction = ui.instruction.value.trim();
|
||||
if (!instruction) { toast("请输入开发指令。", "error"); ui.instruction.focus(); return; }
|
||||
model.pending = true;
|
||||
render();
|
||||
try {
|
||||
const result = await api("/api/firmware-workbench/tasks", {
|
||||
method: "POST", body: JSON.stringify({ instruction, baseline_id: ui.baseline.value }),
|
||||
});
|
||||
model.selectedTaskId = result.task.id;
|
||||
toast("开发任务已创建,流水线开始执行。", "success");
|
||||
await refreshState({ quiet: true });
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
model.pending = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function taskAction(action) {
|
||||
const task = selectedTask();
|
||||
if (!task) return;
|
||||
model.pending = true;
|
||||
render();
|
||||
try {
|
||||
await api(`/api/firmware-workbench/tasks/${encodeURIComponent(task.id)}/${action}`, { method: "POST", body: "{}" });
|
||||
toast(action === "cancel" ? "取消请求已提交。" : "任务已重新进入流水线。", "success");
|
||||
await refreshState({ quiet: true });
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
model.pending = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function appendPrompt(text) {
|
||||
const current = ui.instruction.value.trim();
|
||||
if (current.includes(text)) return;
|
||||
ui.instruction.value = current ? `${current.replace(/[。;;]?$/, "")};${text}。` : `${text}。`;
|
||||
ui.instruction.focus();
|
||||
updateCapabilityPreview();
|
||||
}
|
||||
|
||||
function showModal(title, content) {
|
||||
ui.modalTitle.textContent = title;
|
||||
ui.modalBody.innerHTML = content;
|
||||
ui.modal.hidden = false;
|
||||
document.body.style.overflow = "hidden";
|
||||
refreshIcons();
|
||||
ui.modalClose.focus();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
ui.modal.hidden = true;
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
async function artifactAction(button) {
|
||||
const artifact = model.snapshot.artifacts.find((item) => item.id === button.dataset.artifactId);
|
||||
if (!artifact) return;
|
||||
const action = button.dataset.artifactAction;
|
||||
if (action === "delete") {
|
||||
if (!window.confirm(`确认删除“${artifact.firmware_filename}”及其服务器发布记录吗?此操作不可恢复。`)) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await api(`/api/firmware-workbench/artifacts/${artifact.id}`, { method: "DELETE" });
|
||||
toast(result.message, "success");
|
||||
await refreshState({ quiet: true });
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
finally { button.disabled = false; }
|
||||
return;
|
||||
}
|
||||
if (action === "publish") {
|
||||
button.disabled = true;
|
||||
try {
|
||||
const release = await api(`/api/firmware-workbench/artifacts/${artifact.id}/publish`, { method: "POST", body: "{}" });
|
||||
toast(`${release.message}(${release.id})`, "success");
|
||||
await refreshState({ quiet: true });
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
finally { button.disabled = false; }
|
||||
return;
|
||||
}
|
||||
if (action === "detail") {
|
||||
showModal("固件产物详情", `<div class="detail-grid">
|
||||
<div class="detail-field"><span>软件名称</span><strong>${escapeHtml(artifact.software_name)}</strong></div>
|
||||
<div class="detail-field"><span>版本</span><strong>${escapeHtml(artifact.version)}</strong></div>
|
||||
<div class="detail-field"><span>固件包</span><strong>${escapeHtml(artifact.firmware_filename)}</strong></div>
|
||||
<div class="detail-field"><span>构建时间</span><strong>${escapeHtml(formatDate(artifact.build_time))}</strong></div>
|
||||
<div class="detail-field"><span>固件包大小</span><strong>${escapeHtml(formatSize(artifact.size_bytes))}</strong></div>
|
||||
<div class="detail-field"><span>发布状态</span><strong>${escapeHtml(STATUS_LABELS[artifact.status] || artifact.status)}</strong></div>
|
||||
<div class="detail-field"><span>SHA-256(固件包)</span><strong>${escapeHtml(artifact.sha256)}</strong></div>
|
||||
<div class="detail-field"><span>SHA-256(HEX 镜像)</span><strong>${escapeHtml(artifact.image_sha256)}</strong></div>
|
||||
</div>`);
|
||||
} else if (action === "capabilities") {
|
||||
const diff = artifact.capability_diff || {};
|
||||
showModal("能力差异", `<div class="diff-columns">
|
||||
<div class="diff-panel"><h3>固件基线</h3>${(artifact.baseline_capabilities || []).map((item) => `<p>• ${escapeHtml(item)}</p>`).join("")}</div>
|
||||
<div class="diff-panel added"><h3>目标固件变化</h3>${(diff.added || []).map((item) => `<p>+ ${escapeHtml(item)}</p>`).join("")}${(diff.retained || []).map((item) => `<p>✓ 保留${escapeHtml(item)}</p>`).join("") || "<p>无新增能力</p>"}</div>
|
||||
</div>`);
|
||||
} else if (action === "code") {
|
||||
showModal("代码差异", `<pre class="code-block">${escapeHtml(artifact.code_diff || "暂无代码差异记录")}</pre>`);
|
||||
} else if (action === "validation") {
|
||||
showModal("验证记录", (artifact.validation_records || []).map((test) => `<div class="test-item"><span class="test-mark"><i data-lucide="check"></i></span><div class="test-copy"><strong>${escapeHtml(test.name)}</strong><span>${escapeHtml(test.summary)} · ${escapeHtml(formatDate(test.finished_at))}</span></div></div>`).join("") || `<div class="empty-inline">暂无验证记录</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
ui.start.addEventListener("click", startTask);
|
||||
ui.instruction.addEventListener("input", () => updateCapabilityPreview());
|
||||
ui.baseline.addEventListener("change", () => updateCapabilityPreview());
|
||||
ui.cancel.addEventListener("click", () => taskAction("cancel"));
|
||||
ui.retry.addEventListener("click", () => taskAction("retry"));
|
||||
ui.scrollLogs.addEventListener("click", () => { ui.logs.scrollTop = ui.logs.scrollHeight; });
|
||||
document.querySelectorAll("[data-prompt]").forEach((button) => button.addEventListener("click", () => appendPrompt(button.dataset.prompt)));
|
||||
document.querySelectorAll("[data-inspector-tab]").forEach((button) => button.addEventListener("click", () => { model.inspectorTab = button.dataset.inspectorTab; renderInspector(selectedTask()); refreshIcons(); }));
|
||||
ui.artifacts.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-artifact-action]");
|
||||
if (button) artifactAction(button);
|
||||
});
|
||||
ui.modalClose.addEventListener("click", closeModal);
|
||||
ui.modalCloseFooter.addEventListener("click", closeModal);
|
||||
ui.modal.addEventListener("click", (event) => { if (event.target === ui.modal) closeModal(); });
|
||||
document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !ui.modal.hidden) closeModal(); });
|
||||
|
||||
renderPipeline(null);
|
||||
renderRelease(null);
|
||||
updateCapabilityPreview();
|
||||
refreshIcons();
|
||||
refreshState();
|
||||
})();
|
||||
@@ -33,6 +33,7 @@
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/firmware-workbench-entry.css?v=1" />
|
||||
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
|
||||
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
|
||||
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
||||
@@ -46,6 +47,7 @@
|
||||
<script src="/self-extension.js?v=pet-settings-53-performance" defer></script>
|
||||
<script src="/assistant-chat.js?v=codex-assistant-34" defer></script>
|
||||
<script src="/energy-monitor.js?v=energy-monitor-5-performance" defer></script>
|
||||
<script src="/firmware-workbench-entry.js?v=2" defer></script>
|
||||
</head>
|
||||
<body class="console-v2">
|
||||
<div id="app" aria-busy="true"></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch";
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v322-n7-firmware-merge";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||
const APP_SHELL = [
|
||||
@@ -23,6 +23,11 @@ const APP_SHELL = [
|
||||
"/ui-consistency.css?v=spatial-ui-8",
|
||||
"/codepen-glass-theme.css?v=creative-cloud-6-performance",
|
||||
"/vue-ui.js?v=vue-presentation-3",
|
||||
"/firmware-workbench-entry.css?v=1",
|
||||
"/firmware-workbench-entry.js?v=2",
|
||||
"/firmware-workbench.html",
|
||||
"/firmware-workbench.css?v=5",
|
||||
"/firmware-workbench.js?v=3",
|
||||
"/assets/pets/self-extension-cat.png?v=3",
|
||||
"/assets/pets/self-extension-cat-entrance.png?v=3",
|
||||
"/manifest.webmanifest",
|
||||
|
||||
@@ -29,12 +29,17 @@ const runtimeFiles = [
|
||||
"speech-input.js",
|
||||
"assistant-chat.js",
|
||||
"energy-monitor.js",
|
||||
"firmware-workbench-entry.js",
|
||||
"firmware-workbench.js",
|
||||
"firmware-workbench.html",
|
||||
"styles.css",
|
||||
"console-v2.css",
|
||||
"self-extension.css",
|
||||
"speech-input.css",
|
||||
"assistant-chat.css",
|
||||
"energy-monitor.css",
|
||||
"firmware-workbench-entry.css",
|
||||
"firmware-workbench.css",
|
||||
"ui-consistency.css",
|
||||
"codepen-glass-theme.css",
|
||||
"vue-ui.js"
|
||||
|
||||
Reference in New Issue
Block a user