feat: add local hardware approval leases
This commit is contained in:
+133
-4
@@ -10,6 +10,20 @@ function loadConversationId() {
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
function loadHardwareClientId() {
|
||||
const key = "cognitive-agent.hardware-client-id";
|
||||
const stored = localStorage.getItem(key);
|
||||
if (/^hwc-[a-z0-9-]{8,80}$/.test(stored || "")) return stored;
|
||||
const clientId = `hwc-${crypto.randomUUID()}`;
|
||||
localStorage.setItem(key, clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
function defaultHardwareClientLabel(clientId) {
|
||||
return localStorage.getItem("cognitive-agent.hardware-client-label")
|
||||
|| `测试终端-${clientId.slice(-6)}`;
|
||||
}
|
||||
|
||||
const state = {
|
||||
provider: "openai",
|
||||
executionMode: "dry_run",
|
||||
@@ -23,6 +37,9 @@ const state = {
|
||||
seatFeedbackSource: "simulated",
|
||||
availableSeatAxes: null,
|
||||
connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting",
|
||||
hardwareClientId: loadHardwareClientId(),
|
||||
hardwareAccess: null,
|
||||
hardwareAccessPoller: null,
|
||||
};
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
@@ -2509,6 +2526,89 @@ function showToast(message) {
|
||||
showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
|
||||
}
|
||||
|
||||
function isLocalDecisionHost() {
|
||||
return ["127.0.0.1", "localhost", "::1"].includes(window.location.hostname);
|
||||
}
|
||||
|
||||
function renderHardwareAccess(status) {
|
||||
state.hardwareAccess = status;
|
||||
const statusNode = $("#hardwareAccessStatus");
|
||||
const requestButton = $("#requestHardwareAccess");
|
||||
const releaseButton = $("#releaseHardwareAccess");
|
||||
if (!statusNode || !requestButton || !releaseButton) return;
|
||||
const accessState = status?.state || "none";
|
||||
statusNode.dataset.state = accessState;
|
||||
if (accessState === "approved") {
|
||||
statusNode.textContent = `本地已同意。剩余空闲时间 ${status.idle_remaining_seconds ?? 180} 秒;每次真实执行后重新计时。`;
|
||||
requestButton.hidden = true;
|
||||
releaseButton.hidden = false;
|
||||
} else if (accessState === "pending") {
|
||||
statusNode.textContent = `正在等待本地审批,当前排队第 ${status.queue_position || 1} 位;未获批前不会进入 CAN 链路。`;
|
||||
requestButton.textContent = "更新申请";
|
||||
requestButton.hidden = false;
|
||||
releaseButton.hidden = false;
|
||||
} else if (accessState === "expired") {
|
||||
statusNode.textContent = "因 3 分钟未执行测试,使用权已自动停止;再次使用请重新申请。";
|
||||
requestButton.textContent = "重新申请";
|
||||
requestButton.hidden = false;
|
||||
releaseButton.hidden = true;
|
||||
} else {
|
||||
statusNode.textContent = "尚未申请;真实台架指令不会进入 CAN 链路。";
|
||||
requestButton.textContent = "向本地申请";
|
||||
requestButton.hidden = false;
|
||||
releaseButton.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHardwareAccessStatus({ silent = false } = {}) {
|
||||
try {
|
||||
const query = new URLSearchParams({ client_id: state.hardwareClientId });
|
||||
const response = await apiRequest(`/api/hardware/access/status?${query}`, { cache: "no-store" });
|
||||
if (response.status === 401) return;
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.message || payload.error);
|
||||
renderHardwareAccess(payload);
|
||||
} catch (error) {
|
||||
if (!silent) showToast(`使用权状态读取失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestHardwareAccess() {
|
||||
const input = $("#hardwareClientLabel");
|
||||
const label = input.value.trim() || defaultHardwareClientLabel(state.hardwareClientId);
|
||||
input.value = label;
|
||||
localStorage.setItem("cognitive-agent.hardware-client-label", label);
|
||||
try {
|
||||
const response = await apiRequest("/api/hardware/access/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client_id: state.hardwareClientId, label }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.message || payload.error);
|
||||
renderHardwareAccess(payload);
|
||||
showToast(payload.state === "approved" ? "当前使用权仍然有效" : "申请已发送到台架本机,请等待审批");
|
||||
} catch (error) {
|
||||
showToast(`申请失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseHardwareAccess() {
|
||||
try {
|
||||
const response = await apiRequest("/api/hardware/access/release", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client_id: state.hardwareClientId }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.message || payload.error);
|
||||
renderHardwareAccess(payload);
|
||||
showToast("已停止使用真实台架;再次使用需要重新申请");
|
||||
} catch (error) {
|
||||
showToast(`停止使用失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function selectProvider(provider) {
|
||||
state.provider = provider;
|
||||
$$(".segment[data-provider]").forEach((button) => {
|
||||
@@ -2551,6 +2651,7 @@ function selectExecutionMode(mode) {
|
||||
const simulatedHardware = mode === "simulated_hardware";
|
||||
const approvalRequired = Boolean(state.runtime?.hardware?.approval_required);
|
||||
$("#approvalField").hidden = !hardware || !approvalRequired;
|
||||
$("#hardwareAccessField").hidden = !hardware;
|
||||
$("#simulationFaultField").hidden = !simulatedHardware;
|
||||
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
|
||||
$("#executionBadge").innerHTML = hardware
|
||||
@@ -2791,10 +2892,13 @@ async function loadRuntime() {
|
||||
const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
|
||||
const approvalText = hardware.approval_required ? "需授权令牌" : "调试免令牌";
|
||||
const concurrencyText = hardware.concurrency_policy === "single_flight_fail_fast"
|
||||
? " · 单请求独占"
|
||||
? " · 动作防冲突"
|
||||
: "";
|
||||
const accessText = hardware.access_policy === "local_approval_idle_lease"
|
||||
? ` · 本地审批 · 空闲 ${hardware.access_inactivity_timeout_seconds || 180} 秒自动停止`
|
||||
: "";
|
||||
$("#hardwareNote").textContent = hardware.configured
|
||||
? `${hardware.backend} · ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · ${approvalText}${concurrencyText}`
|
||||
? `${hardware.backend} · ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · ${approvalText}${accessText}${concurrencyText}`
|
||||
: `真实执行已锁定:${profileText};请完成服务端启用、适配器、固件镜像${hardware.approval_required ? "和令牌" : ""}配置`;
|
||||
$("#hardwareAliasNote").textContent = "副驾座椅 → t2g_primary_seat(统一电机、加热和通风执行器)";
|
||||
$("#autoFlashNote").textContent = hardware.auto_flash_enabled
|
||||
@@ -2805,7 +2909,7 @@ async function loadRuntime() {
|
||||
? '<i data-lucide="shield-check"></i>硬件已配置'
|
||||
: '<i data-lucide="shield-off"></i>硬件锁定';
|
||||
$("#hardwareCoverage").textContent = hardware.configured
|
||||
? `可选 · ${approvalText}${concurrencyText}`
|
||||
? `可选 · ${approvalText}${accessText}${concurrencyText}`
|
||||
: "已阻断";
|
||||
$("#hardwareCoverage").className = hardware.configured ? "available" : "blocked";
|
||||
const modeAvailable = state.executionMode === "hardware"
|
||||
@@ -2828,6 +2932,7 @@ async function synchronizePhysicalSeatState() {
|
||||
const query = new URLSearchParams({
|
||||
conversation_id: state.conversationId,
|
||||
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
|
||||
hardware_client_id: state.hardwareClientId,
|
||||
});
|
||||
try {
|
||||
const response = await apiRequest(`/api/hardware/seat-state?${query}`, {
|
||||
@@ -3647,6 +3752,14 @@ async function submitRequest(event) {
|
||||
showToast("请输入车舱控制请求");
|
||||
return;
|
||||
}
|
||||
if (state.executionMode === "hardware"
|
||||
&& !isLocalDecisionHost()
|
||||
&& state.hardwareAccess?.state !== "approved") {
|
||||
showToast(state.hardwareAccess?.state === "pending"
|
||||
? "申请仍在等待本地审批"
|
||||
: "请先申请并获得本地真实台架使用权");
|
||||
return;
|
||||
}
|
||||
const button = $("#runButton");
|
||||
button.disabled = true;
|
||||
button.classList.add("loading");
|
||||
@@ -3674,6 +3787,9 @@ async function submitRequest(event) {
|
||||
conversation_id: state.conversationId,
|
||||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||||
execution_mode: state.executionMode,
|
||||
...(state.executionMode === "hardware"
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
...(state.executionMode === "simulated_hardware"
|
||||
? { simulation_fault: $("#simulationFault").value }
|
||||
: {}),
|
||||
@@ -3711,7 +3827,12 @@ async function submitRequest(event) {
|
||||
} catch (error) {
|
||||
window.clearInterval(state.progressTimer);
|
||||
resetPipeline();
|
||||
if (error.code === "hardware_busy") {
|
||||
if (error.code === "hardware_access_required") {
|
||||
setRunStatus("failed", "ACCESS REQUIRED");
|
||||
$("#resultTitle").textContent = "真实台架使用权无效";
|
||||
await loadHardwareAccessStatus({ silent: true });
|
||||
showToast(error.message);
|
||||
} else if (error.code === "hardware_busy") {
|
||||
setRunStatus("failed", "BENCH BUSY");
|
||||
$("#resultTitle").textContent = "真实台架正在使用";
|
||||
const retryHint = error.retryAfter > 0 ? ` 建议 ${error.retryAfter} 秒后重试。` : "";
|
||||
@@ -3833,6 +3954,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
refreshIcons();
|
||||
initSeatTwin();
|
||||
$("#requestForm").addEventListener("submit", submitRequest);
|
||||
$("#hardwareClientLabel").value = defaultHardwareClientLabel(state.hardwareClientId);
|
||||
$("#requestHardwareAccess").addEventListener("click", requestHardwareAccess);
|
||||
$("#releaseHardwareAccess").addEventListener("click", releaseHardwareAccess);
|
||||
$("#utterance").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
@@ -3867,6 +3991,11 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
|
||||
await loadAuthStatus();
|
||||
await loadRuntime();
|
||||
await loadHardwareAccessStatus({ silent: true });
|
||||
state.hardwareAccessPoller = window.setInterval(
|
||||
() => loadHardwareAccessStatus({ silent: true }),
|
||||
3000,
|
||||
);
|
||||
await synchronizePhysicalSeatState();
|
||||
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
|
||||
$("#requestForm").requestSubmit();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
:root { color-scheme: dark; font-family: Inter, "Microsoft YaHei", system-ui, sans-serif; background: #070b12; color: #edf4ff; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 18% 0%, #17294b 0, transparent 35%), #070b12; }
|
||||
.shell { width: min(1040px, calc(100% - 40px)); margin: 0 auto; padding: 54px 0 80px; }
|
||||
header, .section-title, .policy, .request, .lease-details { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
header { margin-bottom: 34px; }
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: clamp(30px, 5vw, 48px); letter-spacing: -.04em; }
|
||||
h2 { font-size: 22px; }
|
||||
.eyebrow { color: #65a8ff; font-size: 12px; letter-spacing: .16em; font-weight: 800; margin-bottom: 8px; }
|
||||
.lead, small { color: #8d9caf; }
|
||||
.lead { margin-top: 12px; }
|
||||
a, button { font: inherit; }
|
||||
a { color: #cfe4ff; text-decoration: none; border: 1px solid #2b3a50; border-radius: 10px; padding: 10px 14px; }
|
||||
.policy, .card { background: rgba(15, 23, 36, .88); border: 1px solid #26354a; border-radius: 18px; box-shadow: 0 20px 60px rgba(0,0,0,.22); }
|
||||
.policy { padding: 18px 22px; justify-content: flex-start; margin-bottom: 18px; }
|
||||
.policy div { display: grid; gap: 3px; flex: 1; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: #41d390; box-shadow: 0 0 16px #41d390; }
|
||||
#clock { color: #8d9caf; font-variant-numeric: tabular-nums; }
|
||||
.card { padding: 24px; margin-top: 18px; }
|
||||
.active-card { border-color: #315a86; }
|
||||
.section-title { margin-bottom: 20px; }
|
||||
.count { min-width: 30px; height: 30px; display: grid; place-items: center; border-radius: 999px; background: #1a2d48; color: #7fbbff; font-weight: 800; }
|
||||
.empty { padding: 26px 4px; color: #74849a; text-align: center; }
|
||||
.request-list { display: grid; gap: 12px; }
|
||||
.request { padding: 16px; border-radius: 13px; border: 1px solid #26364d; background: #0b121e; }
|
||||
.identity { display: grid; gap: 5px; min-width: 0; }
|
||||
.identity strong { font-size: 17px; }
|
||||
.identity code, .identity span { color: #8394aa; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
button { color: #f4f8ff; background: #1577db; border: 0; border-radius: 9px; padding: 10px 14px; cursor: pointer; font-weight: 750; }
|
||||
button:hover { filter: brightness(1.12); }
|
||||
button:disabled { opacity: .45; cursor: wait; }
|
||||
button.secondary { background: #243348; }
|
||||
button.danger { background: #b83449; }
|
||||
.lease-details { justify-content: flex-start; flex-wrap: wrap; padding: 18px; border-radius: 13px; background: #0b121e; border: 1px solid #284f78; }
|
||||
.lease-details .timer { margin-left: auto; color: #4ce0a0; font-weight: 850; font-size: 22px; font-variant-numeric: tabular-nums; }
|
||||
.message { margin-top: 18px; min-height: 24px; color: #7fbbff; }
|
||||
@media (max-width: 680px) { .shell { width: min(100% - 24px, 1040px); padding-top: 26px; } header, .request, .lease-details { align-items: flex-start; flex-direction: column; } .actions { width: 100%; } .actions button { flex: 1; } .lease-details .timer { margin-left: 0; } }
|
||||
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>真实台架本地决策台</title>
|
||||
<link rel="stylesheet" href="/hardware-control.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">LOCAL BENCH AUTHORITY</p>
|
||||
<h1>真实台架本地决策台</h1>
|
||||
<p class="lead">只有本机可以批准、拒绝或停止终端的真实台架使用权。</p>
|
||||
</div>
|
||||
<a href="/">返回控制台</a>
|
||||
</header>
|
||||
|
||||
<section class="policy">
|
||||
<span class="dot"></span>
|
||||
<div><strong>单终端租约</strong><small>获批终端可连续测试;最后一次真实执行后空闲 180 秒自动停止。</small></div>
|
||||
<span id="clock">正在连接…</span>
|
||||
</section>
|
||||
|
||||
<section class="card active-card">
|
||||
<div class="section-title">
|
||||
<div><p class="eyebrow">ACTIVE LEASE</p><h2>当前使用者</h2></div>
|
||||
<button id="revokeButton" class="danger" type="button" hidden>立即停止使用</button>
|
||||
</div>
|
||||
<div id="activeLease" class="empty">当前无人获批使用真实台架。</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-title">
|
||||
<div><p class="eyebrow">PENDING REQUESTS</p><h2>等待审批</h2></div>
|
||||
<span id="pendingCount" class="count">0</span>
|
||||
</div>
|
||||
<div id="pendingRequests" class="request-list empty">暂时没有申请。</div>
|
||||
</section>
|
||||
|
||||
<p id="message" class="message" role="status"></p>
|
||||
</main>
|
||||
<script src="/hardware-control.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
const activeLease = document.querySelector("#activeLease");
|
||||
const pendingRequests = document.querySelector("#pendingRequests");
|
||||
const pendingCount = document.querySelector("#pendingCount");
|
||||
const revokeButton = document.querySelector("#revokeButton");
|
||||
const message = document.querySelector("#message");
|
||||
let snapshot = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
const node = document.createElement("span");
|
||||
node.textContent = String(value ?? "");
|
||||
return node.innerHTML;
|
||||
}
|
||||
|
||||
function showMessage(value, error = false) {
|
||||
message.textContent = value;
|
||||
message.style.color = error ? "#ff8797" : "#7fbbff";
|
||||
}
|
||||
|
||||
async function api(path, body) {
|
||||
const response = await fetch(path, {
|
||||
method: body ? "POST" : "GET",
|
||||
credentials: "same-origin",
|
||||
headers: body ? { "Content-Type": "application/json" } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (response.status === 401) {
|
||||
location.replace(`/login?next=${encodeURIComponent(location.pathname)}`);
|
||||
throw new Error("请先登录本机服务");
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.message || payload.error);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
snapshot = data;
|
||||
pendingCount.textContent = String(data.pending.length);
|
||||
if (data.active) {
|
||||
const lease = data.active;
|
||||
activeLease.className = "lease-details";
|
||||
activeLease.innerHTML = `
|
||||
<div class="identity"><strong>${escapeHtml(lease.label)}</strong><code>${escapeHtml(lease.client_id)}</code><span>来源 ${escapeHtml(lease.client_address)} · 最近执行 ${escapeHtml(lease.last_activity_at)}</span></div>
|
||||
<div class="timer">${Number(lease.idle_remaining_seconds)}s</div>`;
|
||||
revokeButton.hidden = false;
|
||||
revokeButton.dataset.clientId = lease.client_id;
|
||||
} else {
|
||||
activeLease.className = "empty";
|
||||
activeLease.textContent = "当前无人获批使用真实台架。";
|
||||
revokeButton.hidden = true;
|
||||
}
|
||||
if (!data.pending.length) {
|
||||
pendingRequests.className = "request-list empty";
|
||||
pendingRequests.textContent = "暂时没有申请。";
|
||||
return;
|
||||
}
|
||||
pendingRequests.className = "request-list";
|
||||
pendingRequests.innerHTML = data.pending.map((request) => `
|
||||
<article class="request">
|
||||
<div class="identity"><strong>${escapeHtml(request.label)}</strong><code>${escapeHtml(request.client_id)}</code><span>来源 ${escapeHtml(request.client_address)} · ${escapeHtml(request.requested_at)}</span></div>
|
||||
<div class="actions">
|
||||
<button type="button" data-action="approve" data-client-id="${escapeHtml(request.client_id)}">同意</button>
|
||||
<button type="button" class="secondary" data-action="deny" data-client-id="${escapeHtml(request.client_id)}">拒绝</button>
|
||||
</div>
|
||||
</article>`).join("");
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
render(await api("/api/local/hardware-access"));
|
||||
document.querySelector("#clock").textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
} catch (error) {
|
||||
showMessage(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
pendingRequests.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("button[data-action]");
|
||||
if (!button) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const action = button.dataset.action;
|
||||
render(await api(`/api/local/hardware-access/${action}`, { client_id: button.dataset.clientId }));
|
||||
showMessage(action === "approve" ? "已授予该终端真实台架使用权。" : "已拒绝该终端的申请。");
|
||||
} catch (error) {
|
||||
showMessage(error.message, true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
revokeButton.addEventListener("click", async () => {
|
||||
revokeButton.disabled = true;
|
||||
try {
|
||||
render(await api("/api/local/hardware-access/revoke", { client_id: revokeButton.dataset.clientId }));
|
||||
showMessage("已停止该终端的真实台架使用权;再次使用必须重新申请。");
|
||||
} catch (error) {
|
||||
showMessage(error.message, true);
|
||||
} finally {
|
||||
revokeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 2000);
|
||||
@@ -15,7 +15,7 @@
|
||||
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
||||
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
||||
<script src="/network.js?v=android-pad-1" defer></script>
|
||||
<script src="/app.js?v=seat-hil-8" defer></script>
|
||||
<script src="/app.js?v=seat-hil-9" defer></script>
|
||||
<script type="module" src="/gesture-control.js?v=android-pad-1"></script>
|
||||
<script src="/pwa.js?v=android-pad-1" defer></script>
|
||||
</head>
|
||||
@@ -106,6 +106,16 @@
|
||||
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group hardware-access-field" id="hardwareAccessField" hidden>
|
||||
<label class="field-label" for="hardwareClientLabel">真实台架使用权</label>
|
||||
<input id="hardwareClientLabel" maxlength="64" autocomplete="off" placeholder="终端名称,例如:张三-测试电脑" />
|
||||
<div class="hardware-access-actions">
|
||||
<button id="requestHardwareAccess" type="button">向本地申请</button>
|
||||
<button id="releaseHardwareAccess" type="button" class="secondary" hidden>停止使用</button>
|
||||
</div>
|
||||
<p class="provider-note hardware-access-status" id="hardwareAccessStatus">尚未申请;本次真实执行不会进入 CAN 链路。</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
|
||||
<label class="field-label" for="simulationFault">故障注入场景</label>
|
||||
<select id="simulationFault" aria-label="选择模拟硬件故障">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v8";
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v9";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||
const APP_SHELL = [
|
||||
@@ -6,7 +6,7 @@ const APP_SHELL = [
|
||||
"/index.html",
|
||||
"/styles.css?v=android-pad-2",
|
||||
"/network.js?v=android-pad-1",
|
||||
"/app.js?v=seat-hil-8",
|
||||
"/app.js?v=seat-hil-9",
|
||||
"/gesture-control.js?v=android-pad-1",
|
||||
"/pwa.js?v=android-pad-1",
|
||||
"/manifest.webmanifest",
|
||||
|
||||
@@ -4357,6 +4357,54 @@ pre {
|
||||
}
|
||||
}
|
||||
|
||||
.hardware-access-field {
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(38, 102, 159, 0.24);
|
||||
border-radius: 7px;
|
||||
background: rgba(38, 102, 159, 0.055);
|
||||
}
|
||||
|
||||
.hardware-access-field input {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
margin-top: 8px;
|
||||
padding: 9px 11px;
|
||||
color: var(--ink);
|
||||
background: white;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.hardware-access-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hardware-access-actions button {
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
background: var(--blue);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hardware-access-actions button.secondary {
|
||||
color: var(--ink);
|
||||
background: white;
|
||||
border: 1px solid var(--line-strong);
|
||||
}
|
||||
|
||||
.hardware-access-status[data-state="approved"] { color: var(--teal); font-weight: 750; }
|
||||
.hardware-access-status[data-state="pending"] { color: #9a6800; font-weight: 750; }
|
||||
.hardware-access-status[data-state="expired"] { color: var(--danger); font-weight: 750; }
|
||||
|
||||
/* Android PAD, touch and installable-app refinements ---------------------- */
|
||||
.execution-mode-options {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user