diff --git a/apps/web-console/console-v2.css b/apps/web-console/console-v2.css index 20079cd..b5e43f8 100644 --- a/apps/web-console/console-v2.css +++ b/apps/web-console/console-v2.css @@ -7225,6 +7225,32 @@ body.device-power-drawer-open { border-color: rgba(122, 75, 0, 0.16); } +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-delivery { + flex: 0 0 auto; + padding: 1px 5px; + color: #687776; + background: #e8eceb; + border: 1px solid rgba(91, 107, 105, 0.14); + border-radius: 999px; + font-size: 7px; + font-weight: 900; + line-height: 1.5; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-delivery[data-delivery-state="submitted"] { + color: #087048; + background: #d9f3e5; + border-color: rgba(8, 112, 72, 0.16); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-category { + min-width: 0; + overflow: hidden; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + .console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="CAN0"] { background: #f7f4ec; } @@ -7235,6 +7261,21 @@ body.device-power-drawer-open { border-color: rgba(138, 90, 16, 0.13); } +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="IR"] { + background: #f5f1fb; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="IR"] .v2-can-command-channel { + color: #6c3aa1; + background: #eadcf8; + border-color: rgba(108, 58, 161, 0.14); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="IR"] code, +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="IR"] .v2-can-command-frame { + color: #664086; +} + .console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li code { color: #075f5a; font: 750 9px/1.45 "SFMono-Regular", Consolas, monospace; diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js index 31ea19b..7be340f 100644 --- a/apps/web-console/console-v2.js +++ b/apps/web-console/console-v2.js @@ -44,7 +44,9 @@ if (validatedCommandContext.requestId && requestId && validatedCommandContext.requestId !== requestId) return false; if (validatedCommandContext.utterance && utterance - && validatedCommandContext.utterance !== utterance) return false; + && validatedCommandContext.utterance !== utterance + && !validatedCommandContext.utterance.includes(utterance) + && !utterance.includes(validatedCommandContext.utterance)) return false; return true; } @@ -105,6 +107,22 @@ return `0x300 · ${hexByte(opcode)} ${hexByte(control)} ${littleEndian16(paramA)} ${littleEndian16(paramB)}`; }; + if (capability === "infrared.emit") { + const slot = boundedInteger(parameters.slot, 1, 248); + const action = String(parameters.action || "红外动作").trim(); + const deviceName = String(operation.target?.name || operation.target?.device || "红外外设").trim(); + if (slot !== null) { + return { + channel: "IR", + category: `${deviceName} · ${action}`, + command: `infrared emit ${slot}`, + frame: JSON.stringify({ action: "emit", data: { no: slot }, type: "infrared" }), + frameLabel: "GSCU1B TCP", + ...(parseSource ? { parseSource } : {}), + }; + } + } + const isPassengerSeat = new Set(["passenger", "front-right"]).has(target); if (isPassengerSeat) { const passengerAxes = { @@ -324,23 +342,74 @@ function parsedCanCommands(result = {}) { const configurations = result.function_configs || {}; - const plans = result.command_plans || {}; - const planKeys = new Set(Object.keys(plans)); - const hasCompiledPlan = planKeys.size > 0 || Boolean(result.command_plan); - if (!hasCompiledPlan) return []; + const configurationEntries = Object.entries(configurations); + if (!configurationEntries.length && result.function_config) { + configurationEntries.push(["default", result.function_config]); + } + const executionResults = [ + ...(result.execution_report?.spec?.results || []), + ...(result.operation_results || []), + ...Object.values(result.phase_execution_reports || {}).flatMap( + (report) => report?.spec?.results || [], + ), + ]; + const executionByRef = new Map(); + executionResults.forEach((execution) => { + const ref = String(execution?.ref || ""); + if (!ref) return; + const previous = executionByRef.get(ref); + if (!previous || (!previous.telemetry && execution?.telemetry)) { + executionByRef.set(ref, execution); + } + }); + const submittedStates = new Set(["submitted", "queued", "published", "sent", "delivered"]); + const deliveryForOperation = (operation) => { + const capability = String(operation?.capability || ""); + const operationId = String(operation?.id || ""); + const execution = executionByRef.get(operationId) || executionResults.find((candidate) => { + const ref = String(candidate?.ref || ""); + return ref && operationId && ( + operationId.startsWith(`${ref}-`) || ref.startsWith(`${operationId}-`) + ); + }); + const telemetry = execution?.telemetry || {}; + const mqttMessages = Array.isArray(telemetry.mqtt_messages) ? telemetry.mqtt_messages : []; + const deliveryStates = [ + telemetry.delivery_status, + ...mqttMessages.map((message) => message?.delivery_status), + ].map((value) => String(value || "").toLowerCase()); + const submitted = result.execution_mode === "hardware" + && telemetry.simulated !== true + && ( + deliveryStates.some((value) => submittedStates.has(value)) + || (Array.isArray(telemetry.sent_frames) && telemetry.sent_frames.length > 0) + || telemetry.gateway_acknowledged === true + || telemetry.feedback_observed === true + || telemetry.physical_effect_confirmed === true + ); + const transportLabel = capability === "infrared.emit" + ? "红外 TCP" + : result.control_transport === "wireless_mqtt" ? "无线 CAN" : "有线 CAN"; + return { + deliveryState: submitted ? "submitted" : "not_submitted", + deliveryLabel: submitted ? "下发" : "未下发", + deliveryTransport: transportLabel, + }; + }; const commands = []; - Object.entries(configurations).forEach(([configKey, configuration]) => { - if (planKeys.size > 0 && !planKeys.has(configKey)) return; + configurationEntries.forEach(([_configKey, configuration]) => { (configuration?.spec?.operations || []).forEach((operation) => { const item = parsedCanCommandForOperation(operation); - if (item) commands.push(item); + if (item) commands.push({ ...item, ...deliveryForOperation(operation) }); }); }); const unique = new Map(); commands.forEach((item) => { const key = `${item.channel}:${item.command}`; const previous = unique.get(key); - if (!previous || (previous.parseSource !== "local_rule" && item.parseSource === "local_rule")) { + if (!previous + || (previous.deliveryState !== "submitted" && item.deliveryState === "submitted") + || (previous.parseSource !== "local_rule" && item.parseSource === "local_rule")) { unique.set(key, item); } }); @@ -357,17 +426,18 @@ panel.dataset.state = commands.length ? "ready" : message ? "pending" : "empty"; if (commands.length) { list.innerHTML = `
    ${commands.map((item) => ` -
  1. +
  2. ${escapeHtml(item.channel)} ${item.parseSource ? `${item.parseSource === "direct_control" ? "设备控件" : item.parseSource === "local_rule" ? "本地规则" : "云端模型"}` : ""} - ${escapeHtml(item.category)} + ${escapeHtml(item.deliveryLabel)} + ${escapeHtml(item.category)} ${escapeHtml(item.command)} - ${item.frame ? `固件原生 CAN0 · ${escapeHtml(item.frame)}` : ""} + ${item.frame ? `${escapeHtml(item.frameLabel || "固件原生 CAN0")} · ${escapeHtml(item.frame)}` : ""}
  3. `).join("")}
`; } else { - list.innerHTML = `

${escapeHtml(message || "本次请求未生成已验证的 CAN 控制指令")}

`; + list.innerHTML = `

${escapeHtml(message || "本次请求未解析出控制指令")}

`; } requestAnimationFrame(syncSidebarLogBaseline); } @@ -380,15 +450,18 @@ const items = commands.map((command) => ({ channel: command.startsWith("seat passenger ") ? "CAN0" : "CAN1", command, + deliveryState: "not_submitted", + deliveryLabel: "未下发", })); count.textContent = String(items.length); panel.dataset.state = "ready"; list.innerHTML = `
    ${items.map((item) => ` -
  1. +
  2. ${item.channel} 设备控件 - ${escapeHtml(deviceName)} + ${item.deliveryLabel} + ${escapeHtml(deviceName)} ${escapeHtml(item.command)}
  3. `).join("")}
`; @@ -1205,7 +1278,7 @@ : trace.length ? trace.slice(-4).map((item) => item.label || item.stage || item.event || "执行步骤") : domains.length - ? ["接收编排指令", `编排 ${domains.length} 个设备域`, "下发执行计划", "同步执行结果"] + ? ["接收编排指令", `编排 ${domains.length} 个设备域`, "生成执行计划", "同步执行结果"] : ["等待车机或网页端下发编排任务"]; timeline.innerHTML = stages.map((stage, index) => ` @@ -1258,7 +1331,7 @@ // `running` snapshot for the same task must not erase those commands. if (!validatedCommandsBelongToTask(task)) { resetValidatedCommandContext(); - renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" }); + renderParsedCanCommands(null, { message: "正在解析自然语言并生成控制指令…" }); } } else if (result) { const commands = parsedCanCommands(result); @@ -1451,7 +1524,7 @@ insertVisibleControl(simulationFaultField, "v2-sync-simulation-fault-field"); const canCommandPanel = make("details", "v2-can-command-panel", ` - 已解析 CAN 控制指令 + 已解析控制指令 0 @@ -1550,7 +1623,7 @@ $("#v2LogFilter")?.addEventListener("change", renderSyncLogs); $("#requestForm")?.addEventListener("submit", () => { resetValidatedCommandContext(); - renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" }); + renderParsedCanCommands(null, { message: "正在解析自然语言并生成控制指令…" }); }); window.addEventListener("cognitive-agent:validated-can-commands", (event) => { const result = event.detail || {}; @@ -1558,7 +1631,7 @@ if (validatedCommandsVisible) rememberValidatedCommandContext(result); else resetValidatedCommandContext(); renderParsedCanCommands(result, { - message: "已完成白名单校验,正在下发控制指令…", + message: "控制指令已解析,正在等待传输结果…", }); }); window.addEventListener("cognitive-agent:result", (event) => { @@ -1591,7 +1664,7 @@ }); window.addEventListener("cognitive-agent:request-complete", (event) => { if (event.detail?.error && !validatedCommandsVisible) { - renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" }); + renderParsedCanCommands(null, { message: "本次请求未生成控制指令" }); } if (event.detail?.error) { const error = event.detail.error; diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 072194d..65e6c7d 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -10,8 +10,8 @@ 511工程-智能座舱 - - + + @@ -20,8 +20,8 @@ - - + +
diff --git a/apps/web-console/self-extension.css b/apps/web-console/self-extension.css index 1b3899c..4731701 100644 --- a/apps/web-console/self-extension.css +++ b/apps/web-console/self-extension.css @@ -2383,7 +2383,7 @@ form > .v2-extension-primary { .v2-gscu-actions > header { display: grid; - grid-template-columns: minmax(0, 1fr) 110px minmax(150px, 0.7fr); + grid-template-columns: minmax(110px, 0.65fr) 78px minmax(120px, 0.75fr) repeat(2, minmax(145px, 1fr)); align-items: end; gap: 12px; } @@ -2455,9 +2455,9 @@ form > .v2-extension-primary { .v2-gscu-command-catalog > header small { color: #7b8b89; font-size: 8px; } #extensionGscuNamedCommands { - max-height: 180px; + max-height: min(42vh, 360px); display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); gap: 8px; margin-top: 8px; overflow: auto; @@ -2475,7 +2475,66 @@ form > .v2-extension-primary { text-align: center; } -#extensionGscuNamedCommands > article { +.v2-gscu-device-group { + min-width: 0; + padding: 9px; + background: #f2f7f6; + border: 1px solid #d8e5e2; + border-radius: 11px; +} + +.v2-gscu-device-group > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 1px 2px 8px; +} + +.v2-gscu-device-group > header > div { + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: baseline; + gap: 2px 8px; +} + +.v2-gscu-device-group > header small { + color: #6a9691; + font: 800 7px "SFMono-Regular", Consolas, monospace; + letter-spacing: 0.08em; +} + +.v2-gscu-device-group > header strong { + overflow: hidden; + color: #234d49; + font: 850 11px "SFMono-Regular", Consolas, monospace; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-gscu-device-group > header span { + grid-column: 1 / -1; + overflow: hidden; + color: #728784; + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-gscu-device-group > header > b { + flex: none; + color: #4d7773; + font-size: 8px; +} + +.v2-gscu-device-command-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.v2-gscu-device-command-grid > article { display: grid; gap: 8px; padding: 10px; @@ -2537,9 +2596,28 @@ form > .v2-extension-primary { #extensionGscuNamedCommands button.danger { color: #a64639; border-color: #ebcfc9; } +.v2-gscu-result { + margin-top: 12px; + border: 1px solid #d7e4e1; + border-radius: 9px; + background: #fff; +} + +.v2-gscu-result summary { + padding: 10px 12px; + color: #607674; + font-size: 9px; + font-weight: 850; + cursor: pointer; +} + +.v2-gscu-result[open] summary { + border-bottom: 1px solid #d7e4e1; +} + #extensionGscuTcpResult { max-height: 170px; - margin: 12px 0 0; + margin: 0; padding: 10px; overflow: auto; color: #d7f4ef; @@ -2556,7 +2634,7 @@ form > .v2-extension-primary { .v2-gscu-action-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .v2-gscu-advanced[open] { grid-template-columns: 1fr; } .v2-gscu-actions > header { grid-template-columns: 1fr; } - #extensionGscuNamedCommands { grid-template-columns: 1fr; } + .v2-gscu-device-command-grid { grid-template-columns: 1fr; } } .v2-extension-synced-device { @@ -3140,6 +3218,12 @@ form > .v2-extension-primary { border-top: 1px solid #e3ecea; } +.v2-nfc-existing > small { + color: #7c908d; + font-size: 9px; + line-height: 1.5; +} + .v2-nfc-existing > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -3178,6 +3262,90 @@ form > .v2-extension-primary { font-weight: 850; } +.v2-nfc-select-all, +.v2-nfc-command-option { + min-width: 0; + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + gap: 9px; + padding: 11px 12px; + color: #294e4a; + background: #f7faf9; + border: 1px solid #dce8e5; + border-radius: 10px; + cursor: pointer; +} + +.v2-nfc-select-all { + margin-bottom: 8px; + background: #edf7f4; + border-color: #cfe4df; +} + +.v2-nfc-select-all input, +.v2-nfc-command-option input { + width: 17px; + height: 17px; + margin: 0; + accent-color: #278f84; +} + +.v2-nfc-select-all > span, +.v2-nfc-command-option > span { + min-width: 0; + display: grid; + gap: 3px; +} + +.v2-nfc-select-all strong, +.v2-nfc-command-option strong { + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-nfc-select-all small, +.v2-nfc-command-option small { + overflow: hidden; + color: #718885; + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-nfc-command-list { + max-height: min(34vh, 290px); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + overflow: auto; +} + +.v2-nfc-command-option:has(input:not(:checked)) { + color: #778a88; + background: #fafbfb; + border-style: dashed; +} + +.v2-nfc-no-devices { + display: flex; + align-items: center; + gap: 10px; + margin-top: 14px; + padding: 14px; + color: #6b817f; + background: #f7f9f9; + border: 1px dashed #d6e2df; + border-radius: 11px; +} + +.v2-nfc-no-devices > svg { width: 19px; flex: none; } +.v2-nfc-no-devices > div { display: grid; gap: 3px; } +.v2-nfc-no-devices strong { color: #355956; font-size: 11px; } +.v2-nfc-no-devices span { font-size: 9px; line-height: 1.5; } + .v2-nfc-capability-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -3282,6 +3450,12 @@ form > .v2-extension-primary { margin-top: 15px; } +.v2-nfc-recognized-actions > span { + align-self: center; + color: #718784; + font-size: 9px; +} + .v2-nfc-recognized-actions button { min-height: 40px; } @@ -3349,7 +3523,8 @@ form > .v2-extension-primary { } .v2-nfc-create-grid, - .v2-nfc-capability-grid { + .v2-nfc-capability-grid, + .v2-nfc-command-list { grid-template-columns: 1fr; } diff --git a/apps/web-console/self-extension.js b/apps/web-console/self-extension.js index 533e418..021406b 100644 --- a/apps/web-console/self-extension.js +++ b/apps/web-console/self-extension.js @@ -476,14 +476,12 @@ saved.infraredCommands = Array.isArray(saved.infraredCommands) ? saved.infraredCommands.filter((item) => ( validSlot(item?.slot) - && typeof item?.name === "string" - && item.name.trim() - )).map((item) => ({ - slot: Number(item.slot), - name: item.name.trim().slice(0, 48), - source: String(item.source || "tcp"), - learnedAt: item.learnedAt || null, - })) + && ( + String(item?.name || "").trim() + || normalizedInfraredNames(item?.object_names ?? item?.objectNames).length + || normalizedInfraredNames(item?.action_names ?? item?.actionNames).length + ) + )).map(normalizedInfraredSlotRecord) : []; saved.selectedDeviceByType = saved.selectedDeviceByType || {}; saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 }; @@ -724,7 +722,7 @@
-
TCP 动作调试与 gscu1b_control.py tcp 使用相同 JSON 报文
+
TCP 动作调试设备 ID 唯一标识控制对象;对象与动作可填写多个自然语言别名
@@ -744,8 +742,8 @@
-
已学习红外信号槽位与名称保存在本机数据库,并按槽位号排列
-
等待操作
+
已学习红外信号按设备 ID 分组;对象名称用于自然语言识别和调试,动作请包含结果值,如“切换为蓝色”
+
JSON 调试结果
等待操作
`; @@ -817,7 +815,7 @@ $("b", status).textContent = connected ? "READY" : listening ? "WAIT" : "OFFLINE"; if (refreshControls) { renderGscuNamedCommands(); - syncGscuCommandNameFromSlot(); + syncGscuCommandNamesFromSlot(); } } @@ -826,24 +824,143 @@ return [...state.infraredCommands].sort((left, right) => Number(left.slot) - Number(right.slot)); } + function normalizedInfraredNames(value) { + const values = Array.isArray(value) + ? value + : String(value || "").split(/[,,、;;|]+/); + return [...new Set(values + .map((item) => String(item || "").trim().slice(0, 48)) + .filter(Boolean))].slice(0, 24); + } + + function normalizedInfraredDeviceId(value) { + return String(value || "").trim().slice(0, 64); + } + function normalizedInfraredSlotRecord(item) { + const objectNames = normalizedInfraredNames(item.object_names ?? item.objectNames); + const actionNames = normalizedInfraredNames(item.action_names ?? item.actionNames); + const legacyName = String(item.name || "").trim().slice(0, 48); return { slot: Number(item.slot), - name: String(item.name || "").trim().slice(0, 48), + deviceId: normalizedInfraredDeviceId(item.device_id ?? item.deviceId), + name: legacyName || `${objectNames[0] || "未命名对象"} · ${actionNames[0] || "未命名动作"}`, + objectNames, + actionNames: actionNames.length || objectNames.length ? actionNames : normalizedInfraredNames(legacyName), source: String(item.source || "tcp"), learnedAt: item.learned_at || item.learnedAt || null, updatedAt: item.updated_at || item.updatedAt || null, }; } + function infraredDeviceGroups() { + const groups = new Map(); + namedInfraredCommands().forEach((command) => { + const deviceId = normalizedInfraredDeviceId(command.deviceId); + const key = deviceId || ""; + if (!groups.has(key)) { + groups.set(key, { deviceId, objectNames: [], commands: [] }); + } + const group = groups.get(key); + group.commands.push(command); + group.objectNames = normalizedInfraredNames([ + ...group.objectNames, + ...command.objectNames, + ]); + }); + return [...groups.values()].sort((left, right) => { + if (!left.deviceId) return 1; + if (!right.deviceId) return -1; + return left.deviceId.localeCompare(right.deviceId); + }); + } + + function reconcileDevicesAndCapabilitiesFromSlots() { + const configuredKeys = new Set(); + infraredDeviceGroups().filter((group) => group.deviceId).forEach((group) => { + let device = state.devices.find((item) => item.id === group.deviceId); + if (!device) { + device = { + id: group.deviceId, + name: group.objectNames[0] || group.deviceId, + type: "other", + aliases: group.objectNames.length ? group.objectNames : [group.deviceId], + toyNumber: null, + cardUid: null, + createdAt: new Date().toISOString(), + source: "infrared_slot_catalog", + }; + state.devices.push(device); + } else { + device.name = group.objectNames[0] || device.name || group.deviceId; + device.aliases = group.objectNames.length ? group.objectNames : [device.name]; + } + group.commands.forEach((command) => { + const key = `${group.deviceId}\u0000${command.slot}`; + configuredKeys.add(key); + let capability = state.capabilities.find((item) => ( + item.deviceId === group.deviceId && Number(item.slot) === Number(command.slot) + )); + if (!capability) { + capability = { + id: `ir-slot:${group.deviceId}:${command.slot}`, + deviceId: group.deviceId, + key: `slot_${command.slot}`, + createdAt: new Date().toISOString(), + enabled: true, + }; + state.capabilities.push(capability); + } + Object.assign(capability, { + deviceId: group.deviceId, + name: command.actionNames[0] || command.name, + aliases: command.actionNames.length ? command.actionNames : [command.name], + category: "infrared_slot", + risk: capability.risk || "low", + slot: Number(command.slot), + status: "learned", + verified: true, + enabled: capability.enabled !== false, + signalSource: "slot_catalog", + }); + }); + }); + state.capabilities = state.capabilities.filter((capability) => { + if (!["slot_catalog", "backend_catalog"].includes(capability.signalSource)) return true; + return configuredKeys.has(`${capability.deviceId}\u0000${Number(capability.slot)}`); + }); + } + + function configuredCapabilities(deviceId = null) { + const configuredKeys = new Set( + namedInfraredCommands() + .filter((command) => command.deviceId) + .map((command) => `${command.deviceId}\u0000${Number(command.slot)}`), + ); + return state.capabilities.filter((item) => ( + (!deviceId || item.deviceId === deviceId) + && item.status === "learned" + && item.verified + && validSlot(item.slot) + && configuredKeys.has(`${item.deviceId}\u0000${Number(item.slot)}`) + )); + } + function replaceInfraredSlotCatalog(items) { state.infraredCommands = (Array.isArray(items) ? items : []) - .filter((item) => validSlot(item?.slot) && String(item?.name || "").trim()) + .filter((item) => validSlot(item?.slot) && ( + String(item?.name || "").trim() + || normalizedInfraredNames(item?.object_names ?? item?.objectNames).length + || normalizedInfraredNames(item?.action_names ?? item?.actionNames).length + )) .map(normalizedInfraredSlotRecord) .sort((left, right) => left.slot - right.slot); + reconcileDevicesAndCapabilitiesFromSlots(); saveState(); renderGscuNamedCommands(); - syncGscuCommandNameFromSlot(); + syncGscuCommandNamesFromSlot(); + if ($("#extensionNfcDialog")) renderNfcDialog(); + renderUnifiedExtensionView?.(); } async function refreshInfraredSlotCatalog({ migrateLegacy = true } = {}) { @@ -857,7 +974,16 @@ if (missing.length) { await Promise.all(missing.map((item) => api(`gscu/slots/${item.slot}`, { method: "PUT", - body: JSON.stringify({ name: item.name, source: item.source || "browser-migration" }), + body: JSON.stringify( + item.deviceId && item.objectNames?.length && item.actionNames?.length + ? { + device_id: item.deviceId, + object_names: item.objectNames, + action_names: item.actionNames, + source: item.source || "browser-migration", + } + : { name: item.name, source: item.source || "browser-migration" }, + ), }))); result = await api("gscu/slots"); } @@ -866,43 +992,74 @@ return namedInfraredCommands(); } - async function upsertNamedInfraredCommand(slot, name, source = "tcp") { + async function upsertNamedInfraredCommand( + slot, + deviceId, + objectNames, + actionNames, + source = "tcp", + ) { const normalizedSlot = Number(slot); - const normalizedName = String(name || "").trim().slice(0, 48); + const normalizedDeviceId = normalizedInfraredDeviceId(deviceId); + const normalizedObjects = normalizedInfraredNames(objectNames); + const normalizedActions = normalizedInfraredNames(actionNames); if (!validSlot(normalizedSlot)) throw new Error("红外槽位范围是 1–248。"); - if (!normalizedName) throw new Error("学习红外指令前需要填写指令名称。"); + if (!normalizedDeviceId) throw new Error("请填写唯一的设备 ID。"); + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalizedDeviceId)) { + throw new Error("设备 ID 必须以字母或数字开头,且只能包含字母、数字、点、下划线、冒号或连字符。"); + } + if (!normalizedObjects.length) throw new Error("请至少填写一个控制对象名称。"); + if (!normalizedActions.length) throw new Error("请至少填写一个执行动作。"); const result = await api(`gscu/slots/${normalizedSlot}`, { method: "PUT", - body: JSON.stringify({ name: normalizedName, source }), + body: JSON.stringify({ + device_id: normalizedDeviceId, + object_names: normalizedObjects, + action_names: normalizedActions, + source, + }), }); const value = normalizedInfraredSlotRecord(result.slot); const existing = state.infraredCommands.find((item) => item.slot === normalizedSlot); if (existing) Object.assign(existing, value); else state.infraredCommands.push(value); + reconcileDevicesAndCapabilitiesFromSlots(); + const boundDevice = state.devices.find((item) => item.id === value.deviceId); saveState(); renderGscuNamedCommands(); + if (boundDevice) { + renderAll(); + await syncPeripheralCatalog(boundDevice); + } return value; } function renderGscuNamedCommands() { const container = $("#extensionGscuNamedCommands"); if (!container) return; - const commands = namedInfraredCommands(); - container.innerHTML = commands.length - ? commands.map((command) => `
红外槽位IR ${String(command.slot).padStart(3, "0")}
`).join("") - : "

学习成功后将在这里显示编号与指令名称。

"; + const groups = infraredDeviceGroups(); + container.innerHTML = groups.length + ? groups.map((group) => `
${group.deviceId ? "DEVICE ID" : "UNASSIGNED"}${escapeHtml(group.deviceId || "未分配设备 ID")}${escapeHtml(group.objectNames.join("、") || "尚无控制对象描述")}
${group.commands.length} 条命令
${group.commands.map((command) => `
红外槽位IR ${String(command.slot).padStart(3, "0")}
`).join("")}
`).join("") + : "

学习成功后将在这里按设备 ID 显示槽位、控制对象和执行动作。

"; } - function syncGscuCommandNameFromSlot() { + function syncGscuCommandNamesFromSlot() { const slot = Number($("#extensionGscuSlot")?.value); const command = namedInfraredCommands().find((item) => item.slot === slot); - if ($("#extensionGscuCommandName")) { - $("#extensionGscuCommandName").value = command?.name || ""; + if ($("#extensionGscuDeviceId")) { + $("#extensionGscuDeviceId").value = command?.deviceId || ""; + } + if ($("#extensionGscuObjectNames")) { + $("#extensionGscuObjectNames").value = command?.objectNames.join("、") || ""; + } + if ($("#extensionGscuActionNames")) { + $("#extensionGscuActionNames").value = command?.actionNames.join("、") || ""; } } function openGscuTcpDialog(health = healthState || {}) { renderGscuTcpDialog(health); + $("#extensionGscuTcpResultDetails")?.removeAttribute("open"); openDialog("extensionGscuTcpDialog"); void refreshInfraredSlotCatalog().catch((error) => { showToast(`读取本地红外槽位数据库失败:${error.message}`, "error"); @@ -945,7 +1102,9 @@ async function runGscuTcpAction(button) { const action = button.dataset.gscuAction; const slot = Number($("#extensionGscuSlot").value); - const commandName = $("#extensionGscuCommandName").value.trim(); + const deviceId = normalizedInfraredDeviceId($("#extensionGscuDeviceId").value); + const objectNames = normalizedInfraredNames($("#extensionGscuObjectNames").value); + const actionNames = normalizedInfraredNames($("#extensionGscuActionNames").value); const payload = {}; if (["learn", "emit", "read", "write"].includes(action)) payload.slot = slot; if (action === "write") payload.ir_data = $("#extensionGscuIrData").value.trim(); @@ -968,9 +1127,19 @@ payload.confirm = "ERASE_ALL"; } if (action === "reset") payload.confirm = confirmation; - if (action === "learn" && !commandName) { - showToast("请输入该红外编号对应的指令名称。", "error"); - $("#extensionGscuCommandName").focus(); + if (action === "learn" && !deviceId) { + showToast("请输入该红外槽位对应的唯一设备 ID。", "error"); + $("#extensionGscuDeviceId").focus(); + return; + } + if (action === "learn" && !objectNames.length) { + showToast("请输入该红外槽位对应的控制对象名称。", "error"); + $("#extensionGscuObjectNames").focus(); + return; + } + if (action === "learn" && !actionNames.length) { + showToast("请输入该红外槽位对应的执行动作。", "error"); + $("#extensionGscuActionNames").focus(); return; } button.disabled = true; @@ -1006,7 +1175,7 @@ : "学习指令已下发,但槽位回读为空;请重新学习并短按遥控器按键。", ); } - await upsertNamedInfraredCommand(slot, commandName, "tcp"); + await upsertNamedInfraredCommand(slot, deviceId, objectNames, actionNames, "tcp"); result = { ...result, learning_capture_confirmed: true, @@ -1051,7 +1220,7 @@ if (!dialog || dialog.dataset.bound === "true") return; dialog.dataset.bound = "true"; $("#extensionGscuTcpForm").addEventListener("submit", configureGscuTcp); - $("#extensionGscuSlot").addEventListener("change", syncGscuCommandNameFromSlot); + $("#extensionGscuSlot").addEventListener("change", syncGscuCommandNamesFromSlot); dialog.addEventListener("click", (event) => { if (event.target.closest('[data-close-extension-dialog="extensionGscuTcpDialog"]')) { closeDialog("extensionGscuTcpDialog"); @@ -1068,7 +1237,9 @@ const command = namedInfraredCommands().find( (item) => item.slot === Number(emit.dataset.gscuNamedEmit), ); - $("#extensionGscuCommandName").value = command?.name || ""; + $("#extensionGscuDeviceId").value = command?.deviceId || ""; + $("#extensionGscuObjectNames").value = command?.objectNames.join("、") || ""; + $("#extensionGscuActionNames").value = command?.actionNames.join("、") || ""; const emitButton = $("[data-gscu-action=\"emit\"]", dialog); if (emitButton) void runGscuTcpAction(emitButton); return; @@ -1079,10 +1250,12 @@ save.disabled = true; void upsertNamedInfraredCommand( Number(save.dataset.gscuNamedSave), - $("input", card).value, + $("[data-gscu-device-id]", card).value, + $("[data-gscu-object-names]", card).value, + $("[data-gscu-action-names]", card).value, "tcp", ).then((value) => { - showToast(`IR ${value.slot} 已保存为“${value.name}”。`, "success"); + showToast(`IR ${value.slot} 的控制对象与执行动作已保存。`, "success"); }).catch((error) => { showToast(error.message, "error"); }).finally(() => { save.disabled = false; }); @@ -1095,9 +1268,15 @@ if (!window.confirm(`确认删除“${command?.name || `IR ${slot}`}”的本地数据库记录吗?GSCU1B 设备中的该红外码不会被擦除。`)) return; remove.disabled = true; void api(`gscu/slots/${slot}`, { method: "DELETE" }).then((result) => { + const affectedDeviceId = command?.deviceId; state.infraredCommands = namedInfraredCommands().filter((item) => item.slot !== slot); + reconcileDevicesAndCapabilitiesFromSlots(); saveState(); renderGscuNamedCommands(); + renderAll(); + void syncPeripheralCatalog( + state.devices.find((item) => item.id === affectedDeviceId) || null, + ); showToast(result.message || `IR ${slot} 的本地记录已删除。`, "success"); }).catch((error) => { remove.disabled = false; @@ -1660,7 +1839,8 @@ const capability = state.capabilities.find( (item) => item.id === learning.draft.capabilityId, ); - if (!capability) return; + const device = state.devices.find((item) => item.id === capability?.deviceId); + if (!capability || !device) return; const button = $("#extensionConfirmLearn"); button.disabled = true; const relearning = Boolean(learning.draft.relearning); @@ -1668,7 +1848,9 @@ try { await upsertNamedInfraredCommand( Number(learning.draft.slot), - capability.name, + device.id, + [device.name, ...(device.aliases || [])], + [capability.name, ...learning.draft.aliases], "capability", ); } catch (error) { @@ -1715,28 +1897,123 @@ }); saveState(); renderAll(); + void syncPeripheralCatalog(); showToast(`“${capability.name}”已恢复为待学习。`); } function peripheralCatalog() { - return learnedCapabilities().map((capability) => { + return configuredCapabilities().map((capability) => { const device = state.devices.find((item) => item.id === capability.deviceId); const type = TOY_TYPES[device?.type] || TOY_TYPES.other; + const slotRecord = namedInfraredCommands().find( + (item) => item.deviceId === capability.deviceId + && Number(item.slot) === Number(capability.slot), + ); + const objectNames = slotRecord?.objectNames?.length + ? slotRecord.objectNames + : normalizedInfraredNames([device?.name, ...(device?.aliases || [])]); + const actionNames = slotRecord?.actionNames?.length + ? slotRecord.actionNames + : normalizedInfraredNames([capability.name, ...(capability.aliases || [])]); return { id: capability.id, status: "learned", device_id: device?.id, device_name: device?.name, + device_aliases: device?.aliases || [], + object_names: objectNames, device_type: device?.type, card_uid: device?.cardUid || null, toy_number: device?.toyNumber || null, type_label: type.label, action: capability.name, aliases: capability.aliases || [], + action_names: actionNames, risk: capability.risk || "low", slot: capability.slot, + enabled: capability.enabled !== false, + transport: "gscu_tcp", }; - }).filter((item) => item.device_id); + }).filter((item) => item.device_id && item.card_uid && validSlot(item.slot)); + } + + async function hydratePeripheralCatalog() { + const payload = await api("peripherals"); + const catalog = Array.isArray(payload.catalog) ? payload.catalog : []; + if (!catalog.length) return 0; + const restoredDeviceIds = new Set(); + catalog.forEach((item) => { + if (!item || typeof item !== "object" || !item.id || !validSlot(item.slot)) return; + const objectNames = normalizedInfraredNames(item.object_names); + const actionNames = normalizedInfraredNames(item.action_names); + let device = state.devices.find((candidate) => candidate.id === item.device_id); + if (!device) { + device = { + id: item.device_id, + name: objectNames[0] || item.device_name, + type: TOY_TYPES[item.device_type] ? item.device_type : "other", + aliases: objectNames.length + ? objectNames + : Array.isArray(item.device_aliases) + ? item.device_aliases + : [item.device_name], + toyNumber: item.toy_number || nextToyNumber(), + cardUid: normalizeCardUid(item.card_uid), + createdAt: new Date().toISOString(), + }; + state.devices.push(device); + restoredDeviceIds.add(device.id); + } else { + device.name = objectNames[0] || item.device_name || device.name; + device.aliases = objectNames.length + ? objectNames + : Array.isArray(item.device_aliases) + ? item.device_aliases + : device.aliases || [device.name]; + device.cardUid = normalizeCardUid(item.card_uid) || device.cardUid; + } + let capability = state.capabilities.find((candidate) => candidate.id === item.id); + if (!capability) { + capability = { + id: item.id, + deviceId: device.id, + key: "restored", + name: actionNames[0] || item.action, + aliases: actionNames.length + ? actionNames + : Array.isArray(item.aliases) ? item.aliases : [item.action], + category: "restored", + risk: item.risk || "low", + visual: null, + createdAt: new Date().toISOString(), + }; + state.capabilities.push(capability); + } + Object.assign(capability, { + deviceId: device.id, + name: actionNames[0] || item.action, + aliases: actionNames.length + ? actionNames + : Array.isArray(item.aliases) ? item.aliases : capability.aliases || [], + risk: item.risk || "low", + slot: Number(item.slot), + status: "learned", + verified: true, + enabled: item.enabled !== false, + signalSource: "backend_catalog", + }); + }); + restoredDeviceIds.forEach((deviceId) => { + const device = state.devices.find((item) => item.id === deviceId); + if (!device) return; + const knownNames = new Set(deviceCapabilities(deviceId).map((item) => item.name)); + seedPresets(device).forEach((capability) => { + if (!knownNames.has(capability.name)) state.capabilities.push(capability); + }); + }); + saveState(); + renderAll(); + return catalog.length; } function vehicleCatalog() { @@ -3046,6 +3323,7 @@ const reader = nfcSnapshot.reader || {}; const card = nfcSnapshot.card; const linkedDevice = linkedDeviceForCard(card?.uid); + const deviceGroups = infraredDeviceGroups().filter((group) => group.deviceId); status.textContent = nfcReaderNote(reader); badge.textContent = reader.online ? "READER READY" : "READER OFFLINE"; badge.dataset.state = reader.online ? "online" : "offline"; @@ -3055,37 +3333,25 @@ body.innerHTML = `
NFC READER读卡器尚未就绪

${escapeHtml(nfcReaderNote(reader))}

`; } else if (!card?.uid) { stage.dataset.state = "waiting"; - body.innerHTML = `
WAITING FOR TOY请将玩具 NFC 靠近读卡器

让卡片在读卡区停留约 1 秒;识别后会自动显示该玩具及其动作能力。

`; + body.innerHTML = `
WAITING FOR NFC请将 NFC 卡靠近读卡器

让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到一个已配置的设备 ID。

`; } else if (!linkedDevice) { - const suggestedNumber = nextToyNumber(); stage.dataset.state = "new-card"; - body.innerHTML = `
检测到新玩具卡片${escapeHtml(card.uid)}

这张 NFC 尚未绑定,可新建玩具档案或关联到已有玩具。

-
- - -
-
- ${state.devices.length ? `
或绑定到已有玩具
` : ""}`; + body.innerHTML = `
检测到未绑定 NFC${escapeHtml(card.uid)}

请选择设备 ID;控制对象名称仅作为调试辅助说明。

+ ${deviceGroups.length ? `
绑定对象(设备 ID)
关联后,该设备的全部命令默认允许执行,可再逐项取消。
` : `
暂无可绑定的设备 ID请先在 GSCU1B TCP 设置中为已学习红外槽位填写设备 ID。
`}`; } else { - const type = TOY_TYPES[linkedDevice.type] || TOY_TYPES.other; - const capabilities = deviceCapabilities(linkedDevice.id); - const learned = learnedCapabilities(linkedDevice.id); - const firstPending = capabilities.find((capability) => capability.status !== "learned"); + const group = deviceGroups.find((item) => item.deviceId === linkedDevice.id); + const capabilities = configuredCapabilities(linkedDevice.id); + const enabledCount = capabilities.filter((capability) => capability.enabled !== false).length; + const allEnabled = capabilities.length > 0 && enabledCount === capabilities.length; stage.dataset.state = "recognized"; body.innerHTML = `
- -
已识别并绑定 · ${escapeHtml(card.uid)}${linkedDevice.toyNumber ? `#${escapeHtml(linkedDevice.toyNumber)} · ` : ""}${escapeHtml(linkedDevice.name)}

${escapeHtml(type.label)} · ${learned.length} 项动作可执行

+ +
NFC UID · ${escapeHtml(card.uid)}${escapeHtml(linkedDevice.id)}

控制对象:${escapeHtml(group?.objectNames.join("、") || linkedDevice.name || "无辅助描述")} · ${enabledCount}/${capabilities.length} 条命令已授权

-
TOY ACTION CAPABILITIES玩具动作能力
${learned.length}/${capabilities.length} 已学习
-
${capabilities.map((capability) => { - const isLearned = learned.some((item) => item.id === capability.id); - if (!isLearned) { - return `
`; - } - return `
`; - }).join("")}
-
`; +
COMMAND AUTHORIZATION允许此 NFC 执行的命令
${enabledCount}/${capabilities.length} 已选择
+ ${capabilities.length ? `
${capabilities.map((capability) => ``).join("")}
` : `
该设备 ID 暂无有效命令槽位可能已删除或改绑到其他设备 ID,请重新检查红外设置。
`} +
复选框状态会持久化为自然语言执行白名单。
`; } window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); } @@ -3095,12 +3361,12 @@ const dialog = document.createElement("dialog"); dialog.className = "v2-extension-dialog v2-nfc-dialog"; dialog.id = "extensionNfcDialog"; - dialog.innerHTML = `
NFC · INFRARED LEARNING

感应并学习玩具

刷卡识别玩具,再通过现有 GSCU1B 链路学习和验证红外动作。

+ dialog.innerHTML = `
NFC · DEVICE AUTHORIZATION

NFC 设备授权

将 NFC UID 绑定到设备 ID,并限制该卡片可执行的红外命令。

-
靠近读卡器后

玩具 NFC 感应区

正在检查读卡器

READER CHECK
+
靠近读卡器后

NFC 设备绑定

正在检查读卡器

READER CHECK
-
NFC UID 与玩具动作绑定保存在当前浏览器;红外波形保存在 GSCU1B 物理槽位。
`; +
NFC UID、设备 ID 与命令授权同步到本机白名单;红外波形仍保存在 GSCU1B 物理槽位。
`; document.body.append(dialog); } @@ -3131,43 +3397,18 @@ void syncPeripheralCatalog(device); } - function createToyFromNfc() { - const cardUid = nfcSnapshot.card?.uid; - const name = $("#extensionNfcToyName")?.value.trim(); - const typeKey = $("#extensionNfcToyType")?.value; - if (!cardUid) return showToast("请先将玩具 NFC 靠近读卡器。", "error"); - if (!name) return showToast("请输入玩具名称。", "error"); - if (linkedDeviceForCard(cardUid)) return showToast("这张 NFC 已绑定玩具。", "error"); - if (state.devices.some((device) => device.name === name)) { - return showToast("已经存在同名玩具,请改用其他名称。", "error"); - } - const device = { - id: uid("toy"), - name, - type: TOY_TYPES[typeKey] ? typeKey : "other", - aliases: [name], - toyNumber: nextToyNumber(), - cardUid: normalizeCardUid(cardUid), - createdAt: new Date().toISOString(), - }; - const capabilities = seedPresets(device); - state.devices.push(device); - state.capabilities.push(...capabilities); - state.selectedDeviceByType[device.type] = device.id; - saveState(); - renderAll(); - void syncPeripheralCatalog(device); - showToast(`已识别“${device.name}”,请选择遥控动作开始学习。`, "success"); - if (capabilities[0]) openLearn(capabilities[0].id); - } - function bindCurrentNfcToExisting() { const deviceId = $("#extensionNfcExistingDevice")?.value; + reconcileDevicesAndCapabilitiesFromSlots(); const device = state.devices.find((item) => item.id === deviceId); const cardUid = nfcSnapshot.card?.uid; if (!device || !cardUid) return; + configuredCapabilities(device.id).forEach((capability) => { + capability.enabled = true; + }); bindCardToDevice(device, cardUid); - showToast(`NFC 已绑定到“${device.name}”。`, "success"); + renderNfcDialog(); + showToast(`NFC 已关联设备 ID“${device.id}”,全部命令已默认选中。`, "success"); } function unbindCurrentNfc() { @@ -3177,36 +3418,33 @@ saveState(); renderAll(); void syncPeripheralCatalog(device); - showToast(`已解除“${device.name}”的 NFC 绑定。`); + renderNfcDialog(); + showToast(`已解除设备 ID“${device.id}”的 NFC 绑定。`); } - function continueNfcLearning(capabilityId = null) { + function updateNfcCapabilityAuthorization(capabilityId, enabled) { const device = linkedDeviceForCard(nfcSnapshot.card?.uid); - const capability = capabilityId - ? state.capabilities.find((item) => item.id === capabilityId) - : deviceCapabilities(device?.id).find((item) => item.status !== "learned"); - if (!device || !capability) { - showToast("该玩具没有待学习动作。", "success"); - return; - } - state.selectedDeviceByType[device.type] = device.id; + const capability = configuredCapabilities(device?.id).find( + (item) => item.id === capabilityId, + ); + if (!device || !capability) return; + capability.enabled = enabled; saveState(); - openLearn(capability.id); + renderAll(); + renderNfcDialog(); + void syncPeripheralCatalog(device); } - async function emitNfcCapability(capabilityId) { - const capability = learnedCapabilities().find((item) => item.id === capabilityId); - const device = state.devices.find((item) => item.id === capability?.deviceId); - if (!capability || !device) return; - if (capability.risk === "high" && !window.confirm( - `“${capability.name}”可能让玩具移动,请确认周围安全。`, - )) return; - try { - await sendGatewayCommand("emit", { slot: capability.slot }); - showToast(`已向“${device.name}”发射“${capability.name}”。`, "success"); - } catch (error) { - showToast(error.message, "error"); - } + function updateAllNfcCapabilityAuthorizations(enabled) { + const device = linkedDeviceForCard(nfcSnapshot.card?.uid); + if (!device) return; + configuredCapabilities(device.id).forEach((capability) => { + capability.enabled = enabled; + }); + saveState(); + renderAll(); + renderNfcDialog(); + void syncPeripheralCatalog(device); } function applyNfcSnapshot(value, { announce = true } = {}) { @@ -3232,10 +3470,10 @@ if (device) { state.selectedDeviceByType[device.type] = device.id; saveState(); - if (announce) showToast(`已识别玩具“${device.name}”。`, "success"); + if (announce) showToast(`已识别设备 ID“${device.id}”。`, "success"); void syncPeripheralCatalog(device); } else if (announce) { - showToast("检测到新的玩具 NFC,请建立或绑定玩具档案。", "success"); + showToast("检测到未绑定 NFC,请选择一个设备 ID。", "success"); } } renderNfcDialog(); @@ -3275,23 +3513,27 @@ function bindUnifiedNfcEvents(mount) { mount.addEventListener("click", (event) => { - if (event.target.closest("[data-open-nfc-learning]")) openNfcDialog(); + if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog(); if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog(); }); bindGscuTcpDialogEvents(); const dialog = $("#extensionNfcDialog"); dialog.addEventListener("click", (event) => { if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog"); - if (event.target.closest("[data-nfc-create-and-learn]")) createToyFromNfc(); if (event.target.closest("[data-nfc-bind-existing]")) bindCurrentNfcToExisting(); if (event.target.closest("[data-nfc-unbind]")) unbindCurrentNfc(); - const relearn = event.target.closest("[data-nfc-relearn-capability]"); - if (relearn) continueNfcLearning(relearn.dataset.nfcRelearnCapability); - const learn = event.target.closest("[data-nfc-learn-capability]"); - if (learn) continueNfcLearning(learn.dataset.nfcLearnCapability); - if (event.target.closest("[data-nfc-continue-learning]")) continueNfcLearning(); - const run = event.target.closest("[data-nfc-run-capability]"); - if (run) void emitNfcCapability(run.dataset.nfcRunCapability); + }); + dialog.addEventListener("change", (event) => { + const capability = event.target.closest("[data-nfc-capability-enabled]"); + if (capability) { + updateNfcCapabilityAuthorization( + capability.dataset.nfcCapabilityEnabled, + capability.checked, + ); + return; + } + const selectAll = event.target.closest("[data-nfc-select-all]"); + if (selectAll) updateAllNfcCapabilityAuthorizations(selectAll.checked); }); $("#extensionContinueLearn").addEventListener("click", () => { try { @@ -3370,6 +3612,7 @@ ? mergedCatalog.filter((item) => ( normalizeCardUid(item.card_uid) === sensedCardUid && item.status === "learned" + && item.enabled !== false )) : []; const grouped = new Map(); @@ -3387,12 +3630,14 @@ const devices = [...grouped.values()]; const lastAction = snapshot?.extension?.last_action; const lastResult = snapshot?.extension?.result; - const nfcCapabilities = nfcDevice ? learnedCapabilities(nfcDevice.id) : []; + const nfcCapabilities = nfcDevice + ? configuredCapabilities(nfcDevice.id).filter((item) => item.enabled !== false) + : []; const panelCollapsed = mount.closest("#v2ExtensionPanel")?.classList.contains("is-collapsed") === true; const statusLabel = devices.length ? "已感应 NFC 外设" : sensedCardUid - ? "当前 NFC 暂无已学习能力" + ? "当前 NFC 暂无已授权命令" : nfcReader.online ? "等待 NFC 外设靠近" : "NFC 读卡器离线"; @@ -3405,7 +3650,7 @@ : "红外链路检查中"; const gatewayNote = physicalConnected ? `${health.peer || "GSCU1B"} · 红外学习与发射链路已就绪` - : health.message || "红外学习器接入后可学习并发射玩具动作"; + : health.message || "红外学习器接入后可学习并发射设备动作"; const visibleLastAction = devices.length ? lastAction : null; const nfcState = !nfcReader.online ? "offline" @@ -3415,28 +3660,28 @@ ? "new-card" : "waiting"; const nfcTitle = nfcDevice - ? `${nfcDevice.toyNumber ? `#${nfcDevice.toyNumber} · ` : ""}${nfcDevice.name}` + ? nfcDevice.id : nfcCard?.uid - ? "检测到新的玩具 NFC" + ? "检测到未绑定 NFC" : nfcReader.online - ? "等待玩具靠近" + ? "等待 NFC 卡靠近" : "NFC 读卡器未连接"; const nfcNote = nfcDevice - ? `${nfcCapabilities.length} 项已学习动作 · ${nfcCard.uid}` + ? `${nfcCapabilities.length} 条命令已授权 · ${nfcCard.uid}` : nfcCard?.uid - ? `${nfcCard.uid} · 点击建立或绑定玩具档案` + ? `${nfcCard.uid} · 点击关联设备 ID` : nfcReaderNote(nfcReader); mount.dataset.hasDevices = String(devices.length > 0); mount.innerHTML = `
-
USB EXTENSION · LIVE

外设与扩展能力

+
USB EXTENSION · LIVE

外设与扩展能力

${statusLabel}
靠近读卡器后${escapeHtml(nfcTitle)}

${escapeHtml(nfcNote)}

${nfcCapabilities.length ? `
${nfcCapabilities.slice(0, 6).map((capability) => `${escapeHtml(capability.name)}`).join("")}
` : ""}
- +
@@ -3451,7 +3696,7 @@
${device.actions.slice(0, 8).map((action) => `${escapeHtml(action)}`).join("")}
`).join("") : `
-
${sensedCardUid ? "当前 NFC 暂无可显示能力" : "当前未感应到外设"}${sensedCardUid ? "仅显示已绑定并完成红外学习的动作" : "将已绑定且已学习的 NFC 外设靠近读卡器后显示能力"}
+
${sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}${sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}
`}