From aa6f59d3a6a5786fe1975d0dc65ffca9311d011d Mon Sep 17 00:00:00 2001
From: Miayui <774713819@qq.com>
Date: Mon, 14 Sep 2026 15:16:02 +0800
Subject: [PATCH] feat(n7web): add pet NFC workflow and glass UI
---
apps/web-console/README.md | 30 +
apps/web-console/app.js | 88 +--
apps/web-console/codepen-glass-theme.css | 581 ++++++++++++++++++
apps/web-console/index.html | 9 +-
apps/web-console/self-extension.css | 544 ++++++++++++++++
apps/web-console/self-extension.js | 403 +++++++++++-
apps/web-console/service-worker.js | 9 +-
src/cognitive_agent/application/pet_cards.py | 507 +++++++++++++++
.../application/seat_service.py | 5 +
.../infrastructure/can/mqtt_transport.py | 55 +-
src/cognitive_agent/server.py | 124 +++-
tests/e2e/test_seat_web_api.py | 139 ++++-
.../unit/test_assistant_frontend_contract.py | 2 +-
.../test_energy_monitor_frontend_contract.py | 2 +-
tests/unit/test_frontend_theme_contract.py | 27 +-
...st_frontend_vehicle_state_sync_contract.py | 29 +-
tests/unit/test_mqtt_can_transport.py | 37 ++
tests/unit/test_nfc_frontend_contract.py | 59 +-
tests/unit/test_pet_cards.py | 172 ++++++
tests/unit/test_vue_frontend_contract.py | 4 +-
20 files changed, 2724 insertions(+), 102 deletions(-)
create mode 100644 apps/web-console/codepen-glass-theme.css
create mode 100644 src/cognitive_agent/application/pet_cards.py
create mode 100644 tests/unit/test_pet_cards.py
diff --git a/apps/web-console/README.md b/apps/web-console/README.md
index 49ed507..33f297d 100755
--- a/apps/web-console/README.md
+++ b/apps/web-console/README.md
@@ -1,5 +1,35 @@
# Web Console
+## 宠物卡片与 NFC 上报
+
+主页“宠物卡片”区域支持创建多张宠物卡片。`pet_name` 与 `action` 可在卡片中编辑,
+`nfc_id` 为只读字段,只能在“NFC 设备授权”弹窗中使用读卡器当前识别到的实体 NFC
+完成绑定,不能由浏览器或 API 请求直接填写。同一个 NFC UID 最多绑定一张宠物卡片;重新
+绑定时会自动解除旧卡片上的相同 UID。新卡片必须先完成编辑并点击保存,之后才能从
+“NFC 设备绑定”或“关联卡片”入口继续绑定;右侧卡片区域不再直接触发 NFC 绑定。
+
+卡片由后端持久化到 `artifacts/server/pet_cards.sqlite3`,网页关闭或服务重启不会丢失。
+同源接口如下:
+
+- `GET /api/extension/pets`:读取卡片及发布器状态。
+- `POST /api/extension/pets`:创建卡片,只接受 `pet_name`、`action`。
+- `PUT /api/extension/pets/{pet_id}`:修改宠物名和动作。
+- `DELETE /api/extension/pets/{pet_id}`:删除卡片。
+- `POST /api/extension/pets/{pet_id}/bind-current-nfc`:绑定读卡器中当前在场的 NFC。
+- `POST /api/extension/pets/{pet_id}/unbind`:解除绑定。
+
+服务端首次检测到已绑定 NFC 进入读卡区时,后台线程复用当前车辆 MQTT Broker 连接,
+在 1 秒内向固定话题 `n7/pet` 发布 3 帧非保留消息,随后保持静默。只有将 NFC 移出读卡区
+后再次靠近,才会触发下一组 3 帧。消息只包含以下三个字段:
+
+```json
+{"pet_name":"团团","nfc_id":"E113B203","action":"摇尾巴"}
+```
+
+发布行为独立于网页是否打开。若本次 3 帧中存在 Broker 发布失败,卡片与绑定关系仍会
+保存,前端会显示本次突发发送失败;为避免持续发送,服务不会在 NFC 一直停留时无限重试,
+移开并重新靠近后才会发起下一组 3 帧。
+
Web 控制台是车机端的远程自编排与同步显示界面。网页保留一句话指令和预设功能模式入口,请求通过同源服务发送至车机执行;同时同步显示车机任务、编排结果、设备反馈和 USB 扩展能力。真实 CAN 与硬件执行权限仍由服务端安全门禁控制。
当前页面包括:
diff --git a/apps/web-console/app.js b/apps/web-console/app.js
index 061f77e..ce41832 100755
--- a/apps/web-console/app.js
+++ b/apps/web-console/app.js
@@ -1643,37 +1643,37 @@ function createTwinCabin(THREE, scene) {
const TWIN_THEME_PALETTES = {
user: {
- accent: 0x2f80ed,
- accentStrong: 0x1d5fbf,
- accentSoft: 0xdbeeff,
- sceneBackground: 0xeff6ff,
- floor: 0xe5f0fd,
- gridStrong: 0x75a9e8,
- gridSoft: 0xc7daf2,
- fillLight: 0xb7d9ff,
- vehicleBody: 0x91c6f5,
- vehicleDetail: 0x77a9d8,
- vehicleInterior: 0x4d79a6,
- vehicleTrim: 0x284c70,
- vehicleMetal: 0x93b9da,
- vehicleOutline: 0x2879d8,
- vehicleOccluded: 0x9abbdc,
- seatShell: 0x2b76c9,
- seatInsert: 0xcfe7ff,
- seatThermal: 0x72b5f5,
- seatBolster: 0x1b5fae,
- seatStructure: 0x25476e,
- seatEdge: 0x69b4ff,
- seatSeam: 0xe4f2ff,
- glass: 0x4b9bea,
- glassEmissive: 0x164f91,
- glassEdge: 0x1b6fc4,
+ accent: 0x7b70f5,
+ accentStrong: 0x5d4dd9,
+ accentSoft: 0x9ba8ff,
+ sceneBackground: 0x111426,
+ floor: 0x1a2038,
+ gridStrong: 0x758cff,
+ gridSoft: 0x3c496d,
+ fillLight: 0x93b4ff,
+ vehicleBody: 0x729bc9,
+ vehicleDetail: 0x557da8,
+ vehicleInterior: 0x344f72,
+ vehicleTrim: 0x1a2943,
+ vehicleMetal: 0x7896b5,
+ vehicleOutline: 0x73b8ff,
+ vehicleOccluded: 0x526583,
+ seatShell: 0x426fbd,
+ seatInsert: 0xa9c8ea,
+ seatThermal: 0x65a9ed,
+ seatBolster: 0x295694,
+ seatStructure: 0x263b5e,
+ seatEdge: 0x76bdff,
+ seatSeam: 0xcbe4ff,
+ glass: 0x4d8ed2,
+ glassEmissive: 0x183f76,
+ glassEdge: 0x397ac0,
glassGlow: 0x79c7ff,
- roofClear: 0x67aef4,
- roofDark: 0x183b70,
- lamp: 0x2e7fd4,
- lampEmissive: 0x48a4f2,
- lampEdge: 0x1e6bb8,
+ roofClear: 0x649cda,
+ roofDark: 0x1d315c,
+ lamp: 0x4385cf,
+ lampEmissive: 0x55acf4,
+ lampEdge: 0x3476b9,
},
};
@@ -1695,8 +1695,9 @@ function createTwinGroundTexture(THREE, palette, size = 768) {
const context = textureCanvas.getContext("2d");
if (!context) return null;
- const accent = new THREE.Color(palette.accent);
- const gridColor = accent.clone().lerp(new THREE.Color(0xffffff), 0.52);
+ const floorColor = new THREE.Color(palette.floor);
+ const gridStrong = new THREE.Color(palette.gridStrong);
+ const gridSoft = new THREE.Color(palette.gridSoft);
const rgba = (color, alpha) => {
const red = Math.round(color.r * 255);
const green = Math.round(color.g * 255);
@@ -1704,8 +1705,8 @@ function createTwinGroundTexture(THREE, palette, size = 768) {
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
};
const center = size / 2;
- // The white base is fully opaque beneath the vehicle and fades to completely
- // transparent at the outer edge. Theme colors are reserved for the grid.
+ // Keep the ground dark and translucent so it reads as another layer of the
+ // glass workspace instead of a white rectangle inside the themed shell.
const outerRadius = size * 0.48;
context.clearRect(0, 0, size, size);
@@ -1717,11 +1718,11 @@ function createTwinGroundTexture(THREE, palette, size = 768) {
center,
outerRadius,
);
- surfaceGradient.addColorStop(0, "rgba(255, 255, 255, 1)");
- surfaceGradient.addColorStop(0.18, "rgba(255, 255, 255, 1)");
- surfaceGradient.addColorStop(0.48, "rgba(255, 255, 255, 0.72)");
- surfaceGradient.addColorStop(0.75, "rgba(255, 255, 255, 0.28)");
- surfaceGradient.addColorStop(1, "rgba(255, 255, 255, 0)");
+ surfaceGradient.addColorStop(0, rgba(floorColor, 0.96));
+ surfaceGradient.addColorStop(0.2, rgba(floorColor, 0.9));
+ surfaceGradient.addColorStop(0.52, rgba(floorColor, 0.58));
+ surfaceGradient.addColorStop(0.78, rgba(floorColor, 0.2));
+ surfaceGradient.addColorStop(1, rgba(floorColor, 0));
context.fillStyle = surfaceGradient;
context.fillRect(0, 0, size, size);
@@ -1733,7 +1734,7 @@ function createTwinGroundTexture(THREE, palette, size = 768) {
for (let index = 0; index <= 40; index += 1) {
const position = index * gridStep;
const major = index % 5 === 0;
- gridContext.strokeStyle = rgba(gridColor, major ? 0.32 : 0.16);
+ gridContext.strokeStyle = rgba(major ? gridStrong : gridSoft, major ? 0.38 : 0.24);
gridContext.lineWidth = major ? 0.9 : 0.45;
gridContext.beginPath();
gridContext.moveTo(position, 0);
@@ -3944,8 +3945,9 @@ function initSeatTwin() {
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
- scene.background = new THREE.Color(0xeff6ff);
- scene.fog = new THREE.Fog(0xeff6ff, 12, 19);
+ const twinPalette = TWIN_THEME_PALETTES.user;
+ scene.background = new THREE.Color(twinPalette.sceneBackground);
+ scene.fog = new THREE.Fog(twinPalette.sceneBackground, 12, 19);
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 40);
const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x7d8b90, 2.2);
hemisphereLight.layers.enable(1);
@@ -3960,7 +3962,7 @@ function initSeatTwin() {
keyLight.shadow.camera.top = 6;
keyLight.shadow.camera.bottom = -4;
scene.add(keyLight);
- const fillLight = new THREE.DirectionalLight(0xb7d9ff, 1.15);
+ const fillLight = new THREE.DirectionalLight(twinPalette.fillLight, 1.15);
fillLight.layers.enable(1);
fillLight.position.set(5, 3, -5);
scene.add(fillLight);
diff --git a/apps/web-console/codepen-glass-theme.css b/apps/web-console/codepen-glass-theme.css
new file mode 100644
index 0000000..5e3dada
--- /dev/null
+++ b/apps/web-console/codepen-glass-theme.css
@@ -0,0 +1,581 @@
+/* Glassmorphism workspace theme -------------------------------------------
+ Visual direction adapted to the referenced Creative Cloud concept:
+ a colorful desktop backdrop, one dark translucent app shell, restrained
+ separators and low-contrast glass cards. The existing N7 information
+ architecture and interaction contracts remain unchanged. */
+
+body.console-v2 {
+ color-scheme: dark;
+ --background: oklch(0.16 0.045 286);
+ --foreground: oklch(0.97 0.012 274);
+ --card: oklch(0.25 0.045 282 / 0.68);
+ --card-foreground: oklch(0.95 0.018 274);
+ --popover: oklch(0.205 0.048 284 / 0.96);
+ --popover-foreground: oklch(0.97 0.012 274);
+ --primary: oklch(0.68 0.19 270);
+ --primary-foreground: oklch(0.99 0.004 274);
+ --secondary: oklch(0.31 0.05 281 / 0.72);
+ --secondary-foreground: oklch(0.94 0.018 274);
+ --muted: oklch(0.32 0.035 279 / 0.56);
+ --muted-foreground: oklch(0.78 0.035 276);
+ --accent: oklch(0.41 0.105 273 / 0.58);
+ --accent-foreground: oklch(0.98 0.012 274);
+ --destructive: oklch(0.69 0.2 22);
+ --border: oklch(0.9 0.035 276 / 0.16);
+ --input: oklch(0.93 0.025 276 / 0.22);
+ --ring: oklch(0.75 0.14 265);
+ --sidebar: oklch(0.19 0.05 286 / 0.64);
+ --sidebar-foreground: oklch(0.95 0.018 274);
+ --sidebar-primary: var(--primary);
+ --sidebar-primary-foreground: var(--primary-foreground);
+ --sidebar-accent: oklch(0.36 0.08 276 / 0.54);
+ --sidebar-accent-foreground: var(--foreground);
+ --sidebar-border: var(--border);
+ --sidebar-ring: var(--ring);
+ --heading-foreground: oklch(0.99 0.008 274);
+ --body-foreground: var(--card-foreground);
+ --subtle-foreground: var(--muted-foreground);
+ --status-success: oklch(0.77 0.17 158);
+ --status-online: var(--status-success);
+ --status-warning: oklch(0.82 0.16 82);
+ --status-offline: var(--destructive);
+ --status-info: oklch(0.76 0.14 247);
+ --status-success-foreground: oklch(0.86 0.14 158);
+ --status-warning-foreground: oklch(0.9 0.13 84);
+ --status-info-foreground: oklch(0.88 0.1 248);
+ --status-error-foreground: oklch(0.86 0.11 22);
+ --status-success-soft: oklch(0.67 0.14 158 / 0.15);
+ --status-warning-soft: oklch(0.72 0.15 82 / 0.16);
+ --status-info-soft: oklch(0.65 0.15 250 / 0.17);
+ --status-error-soft: oklch(0.66 0.18 22 / 0.16);
+ --bg: var(--background);
+ --surface: var(--card);
+ --surface-muted: var(--muted);
+ --ink: var(--foreground);
+ --line: var(--border);
+ --line-strong: var(--input);
+ --teal: var(--primary);
+ --teal-dark: color-mix(in oklch, var(--primary) 78%, black);
+ --teal-soft: color-mix(in oklch, var(--primary) 16%, transparent);
+ --blue: var(--primary);
+ --blue-soft: color-mix(in oklch, var(--primary) 16%, transparent);
+ --amber: var(--status-warning);
+ --amber-soft: var(--status-warning-soft);
+ --red: var(--destructive);
+ --red-soft: var(--status-error-soft);
+ --accent-bright: color-mix(in oklch, var(--primary) 78%, white);
+ --accent-ink: var(--primary);
+ --accent-wash: color-mix(in oklch, var(--primary) 14%, transparent);
+ --v2-accent: var(--primary);
+ --v2-accent-strong: color-mix(in oklch, var(--primary) 76%, white);
+ --v2-accent-soft: color-mix(in oklch, var(--primary) 14%, transparent);
+ --v2-sidebar: var(--sidebar);
+ --v2-sidebar-deep: oklch(0.15 0.045 287 / 0.72);
+ --v2-ink: var(--foreground);
+ --v2-muted: var(--muted-foreground);
+ --v2-line: var(--border);
+ --v2-surface: var(--card);
+ --v2-canvas: transparent;
+ --glass-shell: oklch(0.14 0.045 286 / 0.62);
+ --glass-panel: oklch(0.19 0.045 284 / 0.56);
+ --glass-card: oklch(0.43 0.04 276 / 0.18);
+ --glass-card-hover: oklch(0.5 0.07 270 / 0.23);
+ --glass-line: oklch(0.96 0.02 274 / 0.15);
+ --glass-line-strong: oklch(0.96 0.025 274 / 0.24);
+ --glass-highlight: oklch(1 0 0 / 0.14);
+ --glass-shadow: 0 24px 70px oklch(0.06 0.04 285 / 0.42);
+ --glass-panel-shadow: 0 12px 32px oklch(0.05 0.035 285 / 0.24);
+ min-width: 1180px;
+ min-height: 620px;
+ overflow: hidden;
+ color: var(--foreground);
+ background:
+ radial-gradient(circle at 8% 12%, oklch(0.69 0.2 232 / 0.82), transparent 31%),
+ radial-gradient(circle at 78% 2%, oklch(0.45 0.22 290 / 0.82), transparent 34%),
+ radial-gradient(circle at 94% 78%, oklch(0.64 0.25 339 / 0.72), transparent 38%),
+ radial-gradient(circle at 18% 96%, oklch(0.49 0.22 22 / 0.58), transparent 35%),
+ linear-gradient(135deg, oklch(0.17 0.075 242), oklch(0.16 0.075 292) 52%, oklch(0.23 0.11 344));
+ font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+}
+
+body.console-v2::before,
+body.console-v2::after {
+ content: "";
+ position: fixed;
+ z-index: 0;
+ pointer-events: none;
+ border-radius: 999px;
+ filter: blur(44px);
+ opacity: 0.56;
+}
+
+body.console-v2::before {
+ width: 34vw;
+ height: 34vw;
+ top: -18vw;
+ left: 28vw;
+ background: oklch(0.72 0.2 224 / 0.72);
+}
+
+body.console-v2::after {
+ width: 30vw;
+ height: 30vw;
+ right: -10vw;
+ bottom: -16vw;
+ background: oklch(0.67 0.25 334 / 0.68);
+}
+
+body.console-v2 .v2-boot-screen {
+ color: var(--foreground);
+ background:
+ radial-gradient(circle at 50% 42%, color-mix(in oklch, var(--primary) 32%, transparent), transparent 18rem),
+ oklch(0.13 0.04 286);
+}
+
+body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .workspace,
+body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .workspace {
+ width: calc(100vw - 28px) !important;
+ height: calc(100dvh - 28px) !important;
+ min-height: 0;
+ position: relative;
+ z-index: 1;
+ margin: 14px !important;
+ overflow: hidden;
+ color: var(--foreground);
+ background: var(--glass-shell) !important;
+ border: 1px solid var(--glass-line-strong);
+ border-radius: 18px;
+ box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-highlight);
+ -webkit-backdrop-filter: blur(22px) saturate(1.25);
+ backdrop-filter: blur(22px) saturate(1.25);
+}
+
+body.console-v2.v2-passive-display.v2-interactive-sync .main-panel {
+ min-height: 0;
+ color: var(--foreground);
+ background: transparent !important;
+}
+
+body.console-v2.v2-passive-display.v2-interactive-sync .command-panel.v2-sidebar {
+ height: 100%;
+ color: var(--sidebar-foreground);
+ background:
+ linear-gradient(165deg, oklch(0.22 0.07 284 / 0.38), oklch(0.12 0.04 287 / 0.42)) !important;
+ border-right: 1px solid var(--glass-line);
+ -webkit-backdrop-filter: blur(18px) saturate(1.2);
+ backdrop-filter: blur(18px) saturate(1.2);
+}
+
+/* Header / app navigation ------------------------------------------------ */
+body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail {
+ color: var(--foreground);
+ background:
+ linear-gradient(110deg, oklch(0.32 0.08 274 / 0.26), transparent 42%),
+ oklch(0.18 0.045 284 / 0.62) !important;
+ border: 1px solid var(--glass-line);
+ border-radius: 14px;
+ box-shadow: inset 0 1px 0 var(--glass-highlight), 0 8px 24px oklch(0.05 0.04 286 / 0.2);
+ -webkit-backdrop-filter: blur(18px) saturate(1.22);
+ backdrop-filter: blur(18px) saturate(1.22);
+}
+
+body.console-v2 .v2-mode-rail .mode-rail-title,
+body.console-v2 .v2-mode-rail .mode-rail-identity small,
+body.console-v2 .v2-mode-rail .mode-rail-selection {
+ color: var(--foreground);
+}
+
+body.console-v2 .v2-mode-rail .mode-rail-identity small {
+ color: var(--muted-foreground);
+}
+
+body.console-v2 .v2-mode-rail .mode-rail-mark,
+body.console-v2 .v2-panel-control-dock > button,
+body.console-v2 .v2-mode-rail .mode-rail-actions > button,
+body.console-v2 .v2-mode-rail .mode-rail-toggle {
+ color: var(--muted-foreground) !important;
+ background: var(--glass-card) !important;
+ border-color: var(--glass-line) !important;
+ box-shadow: inset 0 1px 0 var(--glass-highlight) !important;
+ -webkit-backdrop-filter: blur(10px) saturate(1.15);
+ backdrop-filter: blur(10px) saturate(1.15);
+}
+
+body.console-v2 .v2-panel-control-dock > button:is(:hover, :focus-visible, .active),
+body.console-v2 .v2-mode-rail .mode-rail-actions > button:is(:hover, :focus-visible),
+body.console-v2 .v2-mode-rail .mode-rail-toggle:is(:hover, :focus-visible) {
+ color: var(--foreground) !important;
+ background: color-mix(in oklch, var(--primary) 58%, transparent) !important;
+ border-color: color-mix(in oklch, var(--primary) 64%, white 18%) !important;
+ box-shadow: 0 7px 18px color-mix(in oklch, var(--primary) 24%, transparent), inset 0 1px 0 oklch(1 0 0 / 0.22) !important;
+}
+
+body.console-v2.v2-passive-display.v2-interactive-sync .v2-mode-rail .mode-chips {
+ background: transparent;
+}
+
+body.console-v2.v2-passive-display.v2-interactive-sync .v2-mode-rail .mode-chips > button {
+ color: var(--muted-foreground) !important;
+ background: transparent !important;
+ border-color: transparent !important;
+ box-shadow: none !important;
+}
+
+body.console-v2.v2-passive-display.v2-interactive-sync .v2-mode-rail .mode-chips > button:is(:hover, :focus-visible) {
+ color: var(--foreground) !important;
+ background: var(--glass-card-hover) !important;
+ border-color: var(--glass-line) !important;
+}
+
+body.console-v2.v2-passive-display.v2-interactive-sync .v2-mode-rail .mode-chips > button::after {
+ background: var(--primary);
+ box-shadow: 0 0 14px color-mix(in oklch, var(--primary) 70%, transparent);
+}
+
+/* Primary glass surfaces ------------------------------------------------- */
+body.console-v2.v2-passive-display.v2-interactive-sync :is(
+ #assistantDrawer,
+ .joint-twin-shell > .twin-band,
+ #v2DevicePanel,
+ #v2ExtensionPanel .v2-extension-sync-mount
+) {
+ color: var(--card-foreground) !important;
+ background:
+ linear-gradient(145deg, oklch(0.36 0.055 278 / 0.12), transparent 44%),
+ var(--glass-panel) !important;
+ border: 1px solid var(--glass-line) !important;
+ border-radius: 14px !important;
+ box-shadow: var(--glass-panel-shadow), inset 0 1px 0 var(--glass-highlight) !important;
+ -webkit-backdrop-filter: blur(20px) saturate(1.2);
+ backdrop-filter: blur(20px) saturate(1.2);
+}
+
+body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync #assistantDrawer .v2-natural-panel {
+ color: var(--card-foreground);
+ background: transparent !important;
+}
+
+body.console-v2 :is(
+ .v2-operation-heading,
+ .joint-twin-shell .twin-heading,
+ .v2-device-sync-board > header,
+ .v2-extension-sync-heading
+) {
+ color: var(--foreground) !important;
+ background: oklch(0.17 0.045 285 / 0.38) !important;
+ border-bottom-color: var(--glass-line) !important;
+}
+
+body.console-v2 :is(
+ .v2-operation-heading h2,
+ .joint-twin-shell .twin-heading h3,
+ .joint-twin-shell .twin-heading .section-kicker,
+ .v2-device-sync-board > header h3,
+ .v2-extension-sync-heading h3
+) {
+ color: var(--heading-foreground) !important;
+}
+
+/* Assistant -------------------------------------------------------------- */
+body.console-v2 .assistant-message-assistant .assistant-message-content,
+body.console-v2 .assistant-message-user .assistant-message-content,
+body.console-v2 .assistant-dispatched-controls {
+ color: var(--card-foreground) !important;
+ background: var(--glass-card) !important;
+ border: 1px solid var(--glass-line) !important;
+ box-shadow: inset 0 1px 0 var(--glass-highlight), 0 8px 20px oklch(0.05 0.035 285 / 0.14) !important;
+ -webkit-backdrop-filter: blur(12px) saturate(1.16);
+ backdrop-filter: blur(12px) saturate(1.16);
+}
+
+body.console-v2 .assistant-avatar {
+ color: var(--primary-foreground) !important;
+ background: linear-gradient(145deg, color-mix(in oklch, var(--primary) 78%, white), var(--primary)) !important;
+ border-color: oklch(1 0 0 / 0.22) !important;
+ box-shadow: 0 8px 20px color-mix(in oklch, var(--primary) 24%, transparent) !important;
+}
+
+body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form .command-composer {
+ color: var(--foreground);
+ background: oklch(0.12 0.035 286 / 0.46) !important;
+ border-color: var(--glass-line-strong) !important;
+ box-shadow: inset 0 1px 0 var(--glass-highlight), 0 10px 24px oklch(0.05 0.04 286 / 0.24) !important;
+ -webkit-backdrop-filter: blur(16px) saturate(1.18);
+ backdrop-filter: blur(16px) saturate(1.18);
+}
+
+body.console-v2.assistant-chat-enabled .v2-request-form #utterance {
+ color: var(--foreground) !important;
+ caret-color: var(--primary);
+ background: transparent !important;
+}
+
+body.console-v2.assistant-chat-enabled .v2-request-form #utterance::placeholder {
+ color: color-mix(in oklch, var(--muted-foreground) 82%, transparent);
+}
+
+body.console-v2 .assistant-drawer-actions :is(.context-reset, .assistant-drawer-close),
+body.console-v2 .v2-request-form :is(.speech-dictation, .v2-reasoning-trigger) {
+ color: var(--muted-foreground) !important;
+ background: transparent !important;
+}
+
+body.console-v2 .assistant-drawer-actions :is(.context-reset, .assistant-drawer-close):is(:hover, :focus-visible),
+body.console-v2 .v2-request-form :is(.speech-dictation, .v2-reasoning-trigger):is(:hover, :focus-visible) {
+ color: var(--foreground) !important;
+ background: var(--glass-card-hover) !important;
+}
+
+body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form .primary-action,
+body.console-v2 .v2-extension-primary,
+body.console-v2 .mode-editor-primary {
+ color: var(--primary-foreground) !important;
+ background: linear-gradient(135deg, color-mix(in oklch, var(--primary) 78%, white), var(--primary)) !important;
+ border-color: color-mix(in oklch, var(--primary) 72%, white 20%) !important;
+ box-shadow: 0 9px 22px color-mix(in oklch, var(--primary) 28%, transparent), inset 0 1px 0 oklch(1 0 0 / 0.25) !important;
+}
+
+/* Vehicle twin ----------------------------------------------------------- */
+body.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels] {
+ color: var(--foreground);
+ background: transparent !important;
+}
+
+body.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-stage {
+ background:
+ radial-gradient(ellipse at 53% 58%, color-mix(in oklch, var(--primary) 25%, transparent), transparent 42%),
+ radial-gradient(circle at 82% 8%, oklch(0.72 0.18 232 / 0.12), transparent 32%),
+ linear-gradient(160deg, oklch(0.18 0.05 285 / 0.2), oklch(0.09 0.03 286 / 0.32)) !important;
+}
+
+body.console-v2 .joint-twin-shell :is(.twin-seat-switch, .twin-seat-switch button, .twin-reset, .twin-focus-badge) {
+ color: var(--muted-foreground) !important;
+ background: var(--glass-card) !important;
+ border-color: var(--glass-line) !important;
+ box-shadow: inset 0 1px 0 var(--glass-highlight) !important;
+}
+
+body.console-v2 .joint-twin-shell .twin-seat-switch button.active,
+body.console-v2 .joint-twin-shell :is(.twin-reset, .twin-focus-badge):is(:hover, :focus-visible) {
+ color: var(--primary-foreground) !important;
+ background: color-mix(in oklch, var(--primary) 72%, transparent) !important;
+ border-color: color-mix(in oklch, var(--primary) 70%, white 18%) !important;
+}
+
+body.console-v2 .v2-twin-hotspot-label,
+body.console-v2 .v2-twin-device-popover {
+ color: var(--foreground) !important;
+ background: oklch(0.16 0.045 285 / 0.74) !important;
+ border-color: var(--glass-line-strong) !important;
+ box-shadow: 0 10px 28px oklch(0.04 0.035 286 / 0.32), inset 0 1px 0 var(--glass-highlight) !important;
+ -webkit-backdrop-filter: blur(14px) saturate(1.18);
+ backdrop-filter: blur(14px) saturate(1.18);
+}
+
+/* Reusable cards, fields and side rails --------------------------------- */
+body.console-v2 :is(
+ .v2-sync-transport-field,
+ .v2-sync-execution-field,
+ .v2-can-command-panel,
+ .v2-sync-log,
+ .v2-nfc-inline,
+ .v2-extension-gateway,
+ .v2-extension-synced-device,
+ .v2-extension-empty,
+ .v2-pet-card-region,
+ .v2-pet-card,
+ .v2-device-card,
+ .joint-seat-panel,
+ .domain-panel,
+ .coverage-card,
+ .decision-list li,
+ .frame-summary,
+ .execution-summary
+) {
+ color: var(--card-foreground) !important;
+ background: var(--glass-card) !important;
+ border-color: var(--glass-line) !important;
+ box-shadow: inset 0 1px 0 var(--glass-highlight), 0 7px 20px oklch(0.05 0.035 286 / 0.15) !important;
+ -webkit-backdrop-filter: blur(13px) saturate(1.16);
+ backdrop-filter: blur(13px) saturate(1.16);
+}
+
+body.console-v2 :is(
+ .v2-nfc-inline,
+ .v2-extension-gateway,
+ .v2-extension-synced-device,
+ .v2-pet-card,
+ .v2-device-card,
+ .joint-seat-panel,
+ .domain-panel
+):is(:hover, :focus-within) {
+ background: var(--glass-card-hover) !important;
+ border-color: var(--glass-line-strong) !important;
+}
+
+body.console-v2 .v2-pet-card-region {
+ --pet-acrylic-surface: var(--glass-card);
+ --pet-acrylic-surface-strong: var(--glass-card-hover);
+ --pet-acrylic-border: var(--glass-line);
+ --pet-acrylic-highlight: var(--glass-highlight);
+}
+
+body.console-v2 :is(input, textarea, select),
+body.console-v2 .v2-pet-nfc-field output {
+ color: var(--foreground) !important;
+ background: oklch(0.11 0.035 286 / 0.42) !important;
+ border-color: var(--glass-line-strong) !important;
+ box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.08) !important;
+ -webkit-backdrop-filter: blur(10px) saturate(1.14);
+ backdrop-filter: blur(10px) saturate(1.14);
+}
+
+body.console-v2 :is(input, textarea, select):focus {
+ border-color: color-mix(in oklch, var(--primary) 68%, white 16%) !important;
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 22%, transparent), inset 0 1px 0 oklch(1 0 0 / 0.1) !important;
+}
+
+body.console-v2 :is(input, textarea)::placeholder {
+ color: color-mix(in oklch, var(--muted-foreground) 76%, transparent);
+}
+
+body.console-v2 :is(
+ .v2-extension-nfc-launch,
+ .v2-extension-gateway > button,
+ .v2-nfc-inline > button,
+ .v2-pet-card-region button,
+ .v2-device-adjust-toggle,
+ .config-action
+) {
+ color: var(--foreground) !important;
+ background: color-mix(in oklch, var(--primary) 52%, transparent) !important;
+ border-color: color-mix(in oklch, var(--primary) 54%, white 14%) !important;
+ box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.18), 0 7px 18px color-mix(in oklch, var(--primary) 18%, transparent) !important;
+}
+
+body.console-v2 :is(
+ .v2-extension-nfc-launch,
+ .v2-extension-gateway > button,
+ .v2-nfc-inline > button,
+ .v2-pet-card-region button,
+ .v2-device-adjust-toggle,
+ .config-action
+):is(:hover, :focus-visible) {
+ color: var(--primary-foreground) !important;
+ background: color-mix(in oklch, var(--primary) 78%, transparent) !important;
+ border-color: color-mix(in oklch, var(--primary) 76%, white 18%) !important;
+}
+
+body.console-v2 .v2-pet-card > header > button {
+ color: var(--muted-foreground) !important;
+ background: transparent !important;
+ border-color: transparent !important;
+ box-shadow: none !important;
+}
+
+body.console-v2 .v2-pet-card > header > button:is(:hover, :focus-visible) {
+ color: var(--status-error-foreground) !important;
+ background: var(--status-error-soft) !important;
+}
+
+body.console-v2 :is(.v2-nfc-inline-icon, .v2-extension-gateway > span, .v2-pet-card > header > span, .v2-pet-card-region > header > div > span) {
+ color: color-mix(in oklch, var(--primary) 74%, white) !important;
+ background: color-mix(in oklch, var(--primary) 18%, transparent) !important;
+ border-color: color-mix(in oklch, var(--primary) 32%, transparent) !important;
+ box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.15) !important;
+}
+
+body.console-v2 :is(.v2-extension-linkage, .v2-sidebar-footer) {
+ color: var(--muted-foreground) !important;
+ border-color: var(--glass-line) !important;
+}
+
+/* Dialogs and secondary pages ------------------------------------------- */
+body.console-v2 :is(
+ .mode-gallery-backdrop,
+ .mode-editor-backdrop,
+ .v2-extension-dialog::backdrop,
+ .energy-monitor-layer
+) {
+ background: oklch(0.055 0.025 286 / 0.62) !important;
+ -webkit-backdrop-filter: blur(14px) saturate(1.12);
+ backdrop-filter: blur(14px) saturate(1.12);
+}
+
+body.console-v2 :is(
+ .mode-gallery,
+ .mode-editor,
+ .v2-nfc-dialog,
+ .v2-gscu-tcp-dialog,
+ .energy-monitor-page
+) {
+ color: var(--card-foreground) !important;
+ background:
+ linear-gradient(145deg, oklch(0.36 0.065 279 / 0.14), transparent 42%),
+ oklch(0.15 0.045 285 / 0.88) !important;
+ border-color: var(--glass-line-strong) !important;
+ box-shadow: 0 28px 84px oklch(0.03 0.03 286 / 0.58), inset 0 1px 0 var(--glass-highlight) !important;
+ -webkit-backdrop-filter: blur(24px) saturate(1.24);
+ backdrop-filter: blur(24px) saturate(1.24);
+}
+
+body.console-v2 :is(
+ .mode-gallery-card,
+ .mode-editor-flow,
+ .mode-editor-command-card,
+ .v2-nfc-body,
+ .v2-nfc-existing,
+ .v2-nfc-pet-binding,
+ .energy-kpi,
+ .energy-trend-card,
+ .energy-devices-card
+) {
+ color: var(--card-foreground) !important;
+ background: var(--glass-card) !important;
+ border-color: var(--glass-line) !important;
+ box-shadow: inset 0 1px 0 var(--glass-highlight), 0 9px 24px oklch(0.04 0.03 286 / 0.2) !important;
+}
+
+body.console-v2 :is(.mode-gallery-close, .mode-editor-header > button, .v2-extension-dialog [data-dialog-close], #energyMonitorClose) {
+ color: var(--muted-foreground) !important;
+ background: var(--glass-card) !important;
+ border-color: var(--glass-line) !important;
+}
+
+body.console-v2 :is(.mode-gallery-close, .mode-editor-header > button, .v2-extension-dialog [data-dialog-close], #energyMonitorClose):is(:hover, :focus-visible) {
+ color: var(--foreground) !important;
+ background: var(--glass-card-hover) !important;
+}
+
+/* Scrollbars ------------------------------------------------------------- */
+body.console-v2 * {
+ scrollbar-color: color-mix(in oklch, var(--primary) 70%, white 8%) oklch(0.08 0.025 286 / 0.32);
+}
+
+body.console-v2 *::-webkit-scrollbar-track {
+ background: oklch(0.08 0.025 286 / 0.28);
+}
+
+body.console-v2 *::-webkit-scrollbar-thumb {
+ background: color-mix(in oklch, var(--primary) 68%, white 10%);
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+
+@media (max-width: 900px), (max-height: 680px) {
+ body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .workspace,
+ body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .workspace {
+ width: 100vw !important;
+ height: 100dvh !important;
+ margin: 0 !important;
+ border-width: 0;
+ border-radius: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ body.console-v2::before,
+ body.console-v2::after {
+ filter: blur(44px);
+ }
+}
diff --git a/apps/web-console/index.html b/apps/web-console/index.html
index 8442d2b..1fe59ae 100755
--- a/apps/web-console/index.html
+++ b/apps/web-console/index.html
@@ -4,18 +4,19 @@
-
+
511工程-智能座舱
-
+
+
@@ -23,12 +24,12 @@
-
+
-
+
diff --git a/apps/web-console/self-extension.css b/apps/web-console/self-extension.css
index 9ff15ba..0c133d1 100644
--- a/apps/web-console/self-extension.css
+++ b/apps/web-console/self-extension.css
@@ -3982,3 +3982,547 @@ form > .v2-extension-primary {
background: var(--status-error-soft);
border-color: color-mix(in oklch, var(--status-error) 34%, var(--border));
}
+
+/* Durable NFC pet cards ------------------------------------------------- */
+.v2-extension-sync-mount {
+ grid-template-rows: auto auto auto auto minmax(0, 1fr) auto;
+}
+
+.console-v2 #v2ExtensionPanel .v2-extension-sync-mount {
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior-y: contain;
+ scrollbar-gutter: stable;
+ touch-action: pan-y;
+}
+
+.v2-pet-card-region {
+ --pet-acrylic-surface: color-mix(in oklch, var(--card) 72%, transparent);
+ --pet-acrylic-surface-strong: color-mix(in oklch, var(--card) 84%, transparent);
+ --pet-acrylic-border: color-mix(in oklch, var(--primary) 20%, color-mix(in oklch, var(--border) 76%, transparent));
+ --pet-acrylic-highlight: color-mix(in oklch, white 58%, transparent);
+ min-width: 0;
+ position: relative;
+ isolation: isolate;
+ display: grid;
+ gap: 12px;
+ padding: 12px;
+ color: var(--card-foreground);
+ background:
+ linear-gradient(145deg, var(--pet-acrylic-surface-strong), var(--pet-acrylic-surface)),
+ color-mix(in oklch, var(--accent) 28%, transparent);
+ border: 1px solid var(--pet-acrylic-border);
+ border-radius: var(--ui-radius-section, 14px);
+ box-shadow:
+ 0 14px 34px color-mix(in oklch, var(--foreground) 9%, transparent),
+ inset 0 1px 0 var(--pet-acrylic-highlight),
+ inset 0 -1px 0 color-mix(in oklch, var(--primary) 8%, transparent);
+ -webkit-backdrop-filter: blur(18px) saturate(1.35);
+ backdrop-filter: blur(18px) saturate(1.35);
+}
+
+.v2-pet-card-region::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ border-radius: inherit;
+ pointer-events: none;
+ background:
+ radial-gradient(circle at 12% 0%, color-mix(in oklch, white 38%, transparent), transparent 38%),
+ linear-gradient(115deg, color-mix(in oklch, var(--primary) 7%, transparent), transparent 44%);
+}
+
+.v2-pet-card-region > header,
+.v2-pet-card-region > header > div,
+.v2-pet-card > header,
+.v2-pet-card > header > div,
+.v2-nfc-pet-binding > div,
+.v2-nfc-pet-binding > div:first-child {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+}
+
+.v2-pet-card-region > header {
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.v2-pet-card-region > header > div {
+ gap: 9px;
+}
+
+.v2-pet-card-region > header > div > span,
+.v2-pet-card > header > span,
+.v2-nfc-pet-binding > div:first-child > span {
+ flex: none;
+ display: grid;
+ place-items: center;
+ color: var(--primary);
+ background: var(--accent);
+ border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
+}
+
+.v2-pet-card-region > header > div > span {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+}
+
+.v2-pet-card-region > header > div > span svg,
+.v2-pet-card-region > header > button svg {
+ width: 16px;
+ height: 16px;
+}
+
+.v2-pet-card-region > header h4 {
+ margin: 0;
+ color: var(--heading-foreground);
+ font-size: 14px;
+ font-weight: 850;
+}
+
+.v2-pet-card-region > header > button,
+.v2-pet-card-empty > button,
+.v2-pet-card footer > button,
+.v2-pet-nfc-field button,
+.v2-nfc-no-pets > button {
+ min-height: 34px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 6px 11px;
+ color: var(--primary-foreground);
+ background: linear-gradient(145deg, color-mix(in oklch, var(--primary) 90%, white), var(--primary));
+ border: 1px solid color-mix(in oklch, var(--primary) 80%, white);
+ border-radius: var(--ui-radius-control-inner, 9px);
+ box-shadow:
+ 0 6px 15px color-mix(in oklch, var(--primary) 24%, transparent),
+ inset 0 1px 0 color-mix(in oklch, white 34%, transparent);
+ font-size: 11px;
+ font-weight: 850;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.v2-pet-card-region button:disabled,
+.v2-nfc-pet-binding button:disabled {
+ opacity: 0.52;
+ cursor: wait;
+}
+
+.v2-pet-card-list {
+ min-height: 0;
+ display: grid;
+ grid-auto-rows: max-content;
+ align-content: start;
+ gap: 12px;
+ overflow: visible;
+}
+
+.v2-pet-card {
+ min-width: 0;
+ position: relative;
+ isolation: isolate;
+ align-self: start;
+ display: grid;
+ gap: 12px;
+ padding: 12px;
+ background:
+ linear-gradient(150deg, color-mix(in oklch, var(--card) 88%, transparent), color-mix(in oklch, var(--accent) 24%, transparent));
+ border: 1px solid var(--pet-acrylic-border);
+ border-radius: var(--ui-radius-item, 12px);
+ box-shadow:
+ 0 9px 24px color-mix(in oklch, var(--foreground) 8%, transparent),
+ inset 0 1px 0 var(--pet-acrylic-highlight);
+ -webkit-backdrop-filter: blur(14px) saturate(1.22);
+ backdrop-filter: blur(14px) saturate(1.22);
+ scroll-margin-block: 12px;
+ transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease;
+}
+
+.v2-pet-card::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ border-radius: inherit;
+ pointer-events: none;
+ background: linear-gradient(118deg, color-mix(in oklch, white 18%, transparent), transparent 34% 72%, color-mix(in oklch, var(--primary) 5%, transparent));
+}
+
+.v2-pet-card:focus-within {
+ border-color: color-mix(in oklch, var(--primary) 42%, var(--border));
+ box-shadow:
+ 0 12px 28px color-mix(in oklch, var(--foreground) 10%, transparent),
+ 0 0 0 3px color-mix(in oklch, var(--primary) 13%, transparent),
+ inset 0 1px 0 var(--pet-acrylic-highlight);
+}
+
+.v2-pet-card[data-pet-state="bursting"],
+.v2-pet-card[data-pet-state="complete"] {
+ border-color: color-mix(in oklch, var(--status-success) 38%, var(--border));
+}
+
+.v2-pet-card[data-pet-state="editing"] {
+ border-color: color-mix(in oklch, var(--status-warning) 38%, var(--border));
+}
+
+.v2-pet-card[data-pet-state="error"] {
+ border-color: color-mix(in oklch, var(--status-error) 40%, var(--border));
+}
+
+.v2-pet-card > header {
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr) auto 28px;
+ gap: 9px;
+}
+
+.v2-pet-card > header > span {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+}
+
+.v2-pet-card > header > span svg,
+.v2-pet-card > header > button svg {
+ width: 16px;
+ height: 16px;
+}
+
+.v2-pet-card > header > strong {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--foreground);
+ font-size: 14px;
+ font-weight: 800;
+}
+
+.v2-pet-card > header > b {
+ padding: 4px 7px;
+ color: var(--muted-foreground);
+ background: color-mix(in oklch, var(--muted) 72%, transparent);
+ border: 1px solid color-mix(in oklch, var(--border) 68%, transparent);
+ border-radius: 999px;
+ box-shadow: inset 0 1px 0 color-mix(in oklch, white 28%, transparent);
+ -webkit-backdrop-filter: blur(8px) saturate(1.18);
+ backdrop-filter: blur(8px) saturate(1.18);
+ font-size: 10px;
+ white-space: nowrap;
+}
+
+.v2-pet-card > header > b[data-state="bursting"],
+.v2-pet-card > header > b[data-state="complete"] {
+ color: var(--status-success-foreground);
+ background: var(--status-success-soft);
+}
+
+.v2-pet-card > header > b[data-state="editing"] {
+ color: var(--status-warning-foreground);
+ background: var(--status-warning-soft);
+}
+
+.v2-pet-card > header > b[data-state="error"] {
+ color: var(--status-error-foreground);
+ background: var(--status-error-soft);
+}
+
+.v2-pet-card > header > button {
+ width: 28px;
+ height: 28px;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ color: var(--muted-foreground);
+ background: transparent;
+ border: 0;
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.v2-pet-card > header > button:hover {
+ color: var(--destructive);
+ background: var(--status-error-soft);
+}
+
+.v2-pet-card-fields {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 10px;
+}
+
+.v2-pet-card-fields > label {
+ min-width: 0;
+ display: grid;
+ grid-column: 1;
+ gap: 6px;
+}
+
+.v2-pet-card-fields > label > span {
+ color: var(--muted-foreground);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.v2-nfc-pet-binding code {
+ color: var(--primary);
+ font: 700 8.5px "SFMono-Regular", Consolas, monospace;
+}
+
+.v2-pet-card-fields input,
+.v2-pet-card-fields textarea {
+ width: 100%;
+ min-width: 0;
+ padding: 9px 10px;
+ color: var(--foreground);
+ background: color-mix(in oklch, var(--background) 68%, transparent);
+ border: 1px solid color-mix(in oklch, var(--input) 80%, transparent);
+ border-radius: var(--ui-radius-control-inner, 9px);
+ box-shadow:
+ inset 0 1px 0 color-mix(in oklch, white 30%, transparent),
+ 0 3px 10px color-mix(in oklch, var(--foreground) 4%, transparent);
+ -webkit-backdrop-filter: blur(10px) saturate(1.18);
+ backdrop-filter: blur(10px) saturate(1.18);
+ outline: none;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 550;
+ line-height: 1.45;
+ resize: vertical;
+}
+
+.v2-pet-card-fields input {
+ height: 40px;
+}
+
+.v2-pet-card-fields textarea {
+ height: 58px;
+ min-height: 58px;
+ max-height: 96px;
+}
+
+.v2-pet-card-fields input:focus,
+.v2-pet-card-fields textarea:focus {
+ border-color: var(--ring);
+ box-shadow: 0 0 0 2px color-mix(in oklch, var(--ring) 12%, transparent);
+}
+
+.v2-pet-nfc-field {
+ grid-column: 1;
+}
+
+.v2-pet-nfc-field > div {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.v2-pet-nfc-field output {
+ min-width: 0;
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+ overflow: hidden;
+ padding: 8px 10px;
+ color: var(--muted-foreground);
+ background: color-mix(in oklch, var(--muted) 58%, transparent);
+ border: 1px dashed color-mix(in oklch, var(--primary) 18%, var(--border));
+ border-radius: var(--ui-radius-control-inner, 9px);
+ box-shadow: inset 0 1px 0 color-mix(in oklch, white 25%, transparent);
+ -webkit-backdrop-filter: blur(9px) saturate(1.15);
+ backdrop-filter: blur(9px) saturate(1.15);
+ font: 700 11px/1.35 "SFMono-Regular", Consolas, monospace;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-pet-card > footer {
+ min-width: 0;
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-end;
+ padding-top: 10px;
+ border-top: 1px solid color-mix(in oklch, var(--border) 74%, transparent);
+}
+
+.v2-pet-card > footer svg {
+ width: 14px;
+ height: 14px;
+}
+
+.v2-pet-card-empty {
+ min-height: 112px;
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ padding: 12px;
+ color: var(--muted-foreground);
+ background: color-mix(in oklch, var(--muted) 58%, transparent);
+ border: 1px dashed var(--pet-acrylic-border);
+ border-radius: var(--ui-radius-item, 12px);
+ box-shadow: inset 0 1px 0 var(--pet-acrylic-highlight);
+ -webkit-backdrop-filter: blur(12px) saturate(1.18);
+ backdrop-filter: blur(12px) saturate(1.18);
+}
+
+.v2-pet-card-empty > svg {
+ width: 24px;
+ color: var(--primary);
+}
+
+.v2-pet-card-empty strong {
+ color: var(--foreground);
+ font-size: 12px;
+}
+
+.v2-nfc-pet-binding {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ margin-top: 14px;
+ padding: 13px;
+ color: var(--card-foreground);
+ background: linear-gradient(145deg, color-mix(in oklch, var(--card) 82%, transparent), color-mix(in oklch, var(--primary) 9%, transparent));
+ border: 1px solid color-mix(in oklch, var(--primary) 24%, color-mix(in oklch, var(--border) 72%, transparent));
+ border-radius: var(--ui-radius-item, 12px);
+ box-shadow:
+ 0 8px 22px color-mix(in oklch, var(--foreground) 7%, transparent),
+ inset 0 1px 0 color-mix(in oklch, white 34%, transparent);
+ -webkit-backdrop-filter: blur(14px) saturate(1.2);
+ backdrop-filter: blur(14px) saturate(1.2);
+}
+
+.v2-nfc-pet-binding > div:first-child {
+ gap: 10px;
+}
+
+.v2-nfc-pet-binding > div:first-child > span {
+ width: 38px;
+ height: 38px;
+ border-radius: 11px;
+}
+
+.v2-nfc-pet-binding > div:first-child > span svg {
+ width: 18px;
+}
+
+.v2-nfc-pet-binding > div:first-child > div {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+
+.v2-nfc-pet-binding small,
+.v2-nfc-pet-binding p {
+ color: var(--muted-foreground);
+ font-size: 9px;
+}
+
+.v2-nfc-pet-binding strong {
+ color: var(--foreground);
+ font-size: 13px;
+}
+
+.v2-nfc-pet-binding p {
+ max-width: 420px;
+ margin: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-nfc-pet-binding > div:last-child {
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.v2-nfc-pet-binding > div:last-child > b {
+ color: var(--status-success-foreground);
+ font-size: 9px;
+ white-space: nowrap;
+}
+
+.v2-nfc-pet-binding button,
+.v2-nfc-no-pets > button {
+ min-height: 34px;
+ padding: 0 12px;
+ color: var(--primary);
+ background: var(--accent);
+ border: 1px solid color-mix(in oklch, var(--primary) 22%, var(--border));
+ border-radius: 9px;
+ font-size: 9px;
+ font-weight: 800;
+ cursor: pointer;
+}
+
+.v2-nfc-pet-existing {
+ background: color-mix(in oklch, var(--primary) 5%, var(--card));
+}
+
+.v2-nfc-no-pets {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+}
+
+.v2-nfc-body > .v2-nfc-existing {
+ box-sizing: border-box;
+ margin-top: 14px;
+ padding: 14px 16px;
+ background: linear-gradient(145deg, color-mix(in oklch, var(--card) 82%, transparent), color-mix(in oklch, var(--primary) 7%, transparent));
+ border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
+ border-radius: var(--ui-radius-item, 12px);
+ box-shadow:
+ 0 8px 22px color-mix(in oklch, var(--foreground) 7%, transparent),
+ inset 0 1px 0 color-mix(in oklch, white 32%, transparent);
+ -webkit-backdrop-filter: blur(14px) saturate(1.2);
+ backdrop-filter: blur(14px) saturate(1.2);
+}
+
+.v2-nfc-body > .v2-nfc-existing > div {
+ grid-template-columns: minmax(0, 1fr) 132px;
+ align-items: center;
+ gap: 12px;
+}
+
+.v2-nfc-body > .v2-nfc-existing button {
+ width: 132px;
+ min-height: 42px;
+ padding-inline: 12px;
+}
+
+@media (max-width: 720px) {
+ .v2-pet-card-fields {
+ grid-template-columns: 1fr;
+ }
+
+ .v2-pet-card-fields > label,
+ .v2-pet-nfc-field {
+ grid-column: 1;
+ }
+
+ .v2-nfc-pet-binding {
+ grid-template-columns: 1fr;
+ }
+
+ .v2-nfc-pet-binding > div:last-child {
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .v2-nfc-pet-binding code {
+ overflow-wrap: anywhere;
+ }
+
+ .v2-nfc-body > .v2-nfc-existing > div {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .v2-nfc-body > .v2-nfc-existing button {
+ width: 100%;
+ }
+}
diff --git a/apps/web-console/self-extension.js b/apps/web-console/self-extension.js
index 2a3c0bc..3f1388c 100644
--- a/apps/web-console/self-extension.js
+++ b/apps/web-console/self-extension.js
@@ -373,6 +373,21 @@
card: null,
legacyProfiles: [],
};
+ let petCardSnapshot = {
+ revision: -1,
+ cards: [],
+ publisher: {
+ state: "stopped",
+ topic: "n7/pet",
+ burst_count: 3,
+ burst_window_ms: 1000,
+ burst_attempt_count: 0,
+ burst_published_count: 0,
+ active_pet_id: null,
+ },
+ };
+ let selectedPetCardId = null;
+ const petCardDrafts = new Map();
let nfcPollTimer = null;
let legacySameOriginChecked = false;
let legacyMigrationCompleted = false;
@@ -3299,6 +3314,277 @@
return String(highest + 1).padStart(3, "0");
}
+ function petCards() {
+ return Array.isArray(petCardSnapshot.cards) ? petCardSnapshot.cards : [];
+ }
+
+ function petCardHasUnsavedChanges(card) {
+ const draft = petCardDrafts.get(card?.id);
+ if (!card || !draft) return false;
+ return String(draft.pet_name ?? "") !== String(card.pet_name ?? "")
+ || String(draft.action ?? "") !== String(card.action ?? "");
+ }
+
+ function petCardCanBind(card) {
+ return Boolean(card?.saved) && !petCardHasUnsavedChanges(card);
+ }
+
+ function linkedPetForCard(uidValue) {
+ const normalizedUid = normalizeCardUid(uidValue);
+ if (!normalizedUid) return null;
+ return petCards().find(
+ (card) => normalizeCardUid(card.nfc_id) === normalizedUid,
+ ) || null;
+ }
+
+ function petCardStatus(card) {
+ const publisher = petCardSnapshot.publisher || {};
+ const active = publisher.active_pet_id === card.id;
+ if (!card.saved || petCardHasUnsavedChanges(card)) {
+ return { state: "editing", label: "待保存" };
+ }
+ if (active && publisher.state === "bursting") {
+ return { state: "bursting", label: "3 帧发送中" };
+ }
+ if (active && publisher.state === "burst_complete") {
+ return { state: "complete", label: "已发送 3 帧" };
+ }
+ if (active && publisher.state === "burst_failed") {
+ return { state: "error", label: "3 帧发送失败" };
+ }
+ if (card.nfc_id) return { state: "bound", label: "已绑定 NFC" };
+ return { state: "unbound", label: "待绑定" };
+ }
+
+ function applyPetCardSnapshot(value, { render = true } = {}) {
+ if (!value || typeof value !== "object") return false;
+ const parsedRevision = Number(value.revision ?? -1);
+ const nextRevision = Number.isFinite(parsedRevision) ? parsedRevision : -1;
+ if (nextRevision < petCardSnapshot.revision) return false;
+ const cards = Array.isArray(value.cards)
+ ? value.cards.filter((card) => card && typeof card === "object")
+ : [];
+ const publisher = value.publisher && typeof value.publisher === "object"
+ ? value.publisher
+ : {};
+ const next = {
+ revision: nextRevision,
+ cards,
+ publisher: {
+ state: String(publisher.state || "stopped"),
+ topic: String(publisher.topic || "n7/pet"),
+ burst_count: Number(publisher.burst_count || 3),
+ burst_window_ms: Number(publisher.burst_window_ms || 1000),
+ burst_attempt_count: Number(publisher.burst_attempt_count || 0),
+ burst_published_count: Number(publisher.burst_published_count || 0),
+ active_pet_id: publisher.active_pet_id || null,
+ active_nfc_id: publisher.active_nfc_id || null,
+ last_published_at: publisher.last_published_at || null,
+ last_delivery_status: publisher.last_delivery_status || null,
+ publish_count: Number(publisher.publish_count || 0),
+ last_error: publisher.last_error || null,
+ },
+ };
+ const cardsChanged = JSON.stringify(petCardSnapshot.cards) !== JSON.stringify(cards);
+ const publisherChanged = JSON.stringify(petCardSnapshot.publisher) !== JSON.stringify(next.publisher);
+ if (!cardsChanged && !publisherChanged && next.revision === petCardSnapshot.revision) {
+ return false;
+ }
+ petCardSnapshot = next;
+ if (selectedPetCardId && !cards.some((card) => card.id === selectedPetCardId)) {
+ selectedPetCardId = null;
+ }
+ if (render) {
+ renderNfcDialog();
+ if (cardsChanged) renderUnifiedExtensionView?.();
+ else updatePetPublisherIndicators();
+ }
+ return true;
+ }
+
+ async function hydratePetCards() {
+ applyPetCardSnapshot(await api("pets"));
+ }
+
+ function nextPetCardName() {
+ const used = new Set(petCards().map((card) => String(card.pet_name || "")));
+ let index = petCards().length + 1;
+ while (used.has(`宠物 ${index}`)) index += 1;
+ return `宠物 ${index}`;
+ }
+
+ async function createPetCard(button = null) {
+ if (button) button.disabled = true;
+ try {
+ const response = await api("pets", {
+ method: "POST",
+ body: JSON.stringify({
+ pet_name: nextPetCardName(),
+ action: "待设置",
+ }),
+ });
+ selectedPetCardId = response.card?.id || null;
+ applyPetCardSnapshot(response);
+ showToast("已添加宠物卡片,请编辑并保存后再绑定 NFC。", "success");
+ window.setTimeout(() => {
+ const card = $(`[data-pet-card-id="${selectedPetCardId}"]`);
+ card?.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ $('[data-pet-field="pet_name"]', card)?.focus();
+ }, 0);
+ } catch (error) {
+ showToast(error.message, "error");
+ } finally {
+ if (button?.isConnected) button.disabled = false;
+ }
+ }
+
+ async function savePetCard(article, button = null) {
+ const cardId = article?.dataset.petCardId;
+ const petName = $('[data-pet-field="pet_name"]', article)?.value || "";
+ const action = $('[data-pet-field="action"]', article)?.value || "";
+ if (!cardId) return;
+ if (button) button.disabled = true;
+ try {
+ const response = await api(`pets/${encodeURIComponent(cardId)}`, {
+ method: "PUT",
+ body: JSON.stringify({ pet_name: petName, action }),
+ });
+ petCardDrafts.delete(cardId);
+ applyPetCardSnapshot(response);
+ showToast("宠物卡片已保存,可通过 NFC 设备授权继续绑定。", "success");
+ } catch (error) {
+ showToast(error.message, "error");
+ } finally {
+ if (button?.isConnected) button.disabled = false;
+ }
+ }
+
+ async function deletePetCard(article, button = null) {
+ const cardId = article?.dataset.petCardId;
+ const petName = $('[data-pet-field="pet_name"]', article)?.value || "该宠物";
+ if (!cardId || !window.confirm(`确认删除“${petName}”宠物卡片吗?`)) return;
+ if (button) button.disabled = true;
+ try {
+ const response = await api(`pets/${encodeURIComponent(cardId)}`, {
+ method: "DELETE",
+ });
+ petCardDrafts.delete(cardId);
+ applyPetCardSnapshot(response);
+ showToast("宠物卡片已删除。", "success");
+ } catch (error) {
+ showToast(error.message, "error");
+ if (button?.isConnected) button.disabled = false;
+ }
+ }
+
+ async function bindCurrentNfcToPet() {
+ const petId = $("#extensionNfcExistingPet")?.value || selectedPetCardId;
+ if (!petId) return;
+ const petCard = petCards().find((card) => card.id === petId);
+ if (!petCardCanBind(petCard)) {
+ showToast("请先在右侧宠物卡片中完成编辑并保存。", "error");
+ return;
+ }
+ const button = $("[data-nfc-bind-pet]");
+ if (button) button.disabled = true;
+ try {
+ const response = await api(`pets/${encodeURIComponent(petId)}/bind-current-nfc`, {
+ method: "POST",
+ body: "{}",
+ });
+ selectedPetCardId = petId;
+ applyPetCardSnapshot(response);
+ renderNfcDialog();
+ showToast(`NFC 已绑定宠物“${response.card?.pet_name || "宠物"}”。`, "success");
+ } catch (error) {
+ showToast(error.message, "error");
+ } finally {
+ if (button?.isConnected) button.disabled = false;
+ }
+ }
+
+ async function unbindPetCard(petId) {
+ if (!petId) return;
+ const button = $(`[data-nfc-unbind-pet="${petId}"]`);
+ if (button) button.disabled = true;
+ try {
+ const response = await api(`pets/${encodeURIComponent(petId)}/unbind`, {
+ method: "POST",
+ body: "{}",
+ });
+ applyPetCardSnapshot(response);
+ renderNfcDialog();
+ showToast("已解除宠物卡片的 NFC 绑定。", "success");
+ } catch (error) {
+ showToast(error.message, "error");
+ } finally {
+ if (button?.isConnected) button.disabled = false;
+ }
+ }
+
+ function renderNfcPetBinding(linkedPet) {
+ if (linkedPet) {
+ const status = petCardStatus(linkedPet);
+ return `
+ 已绑定宠物卡片${escapeHtml(linkedPet.pet_name)}${escapeHtml(linkedPet.action)} · 本次识别仅发送 3 帧
+ ${escapeHtml(linkedPet.nfc_id)}${escapeHtml(status.label)}
+ `;
+ }
+ if (!petCards().length) {
+ return `暂无宠物卡片请先返回右侧“宠物卡片”区域添加、编辑并保存卡片。
`;
+ }
+ const bindableCards = petCards().filter(petCardCanBind);
+ if (!bindableCards.length) {
+ return `暂无可绑定的已保存卡片请先在右侧完成宠物名与动作编辑并点击保存,再打开此弹窗绑定。
`;
+ }
+ const selected = bindableCards.some((card) => card.id === selectedPetCardId)
+ ? selectedPetCardId
+ : bindableCards[0].id;
+ return `绑定宠物卡片卡片 ID 由当前读卡器中的 NFC 自动写入,不能手动编辑;同一 NFC 只绑定一张宠物卡片。 `;
+ }
+
+ function renderPetCardRegion() {
+ const publisher = petCardSnapshot.publisher || {};
+ const cards = petCards();
+ return `
+
+
+ ${cards.length ? cards.map((card) => {
+ const draft = petCardDrafts.get(card.id) || card;
+ const status = petCardStatus(card);
+ const activeError = publisher.active_pet_id === card.id
+ ? String(publisher.last_error || "")
+ : "";
+ return `
+ ${escapeHtml(draft.pet_name || card.pet_name)}${escapeHtml(status.label)}
+
+
+
+
+
+
+ `;
+ }).join("") : `
还没有宠物卡片
`}
+
+ `;
+ }
+
+ function updatePetPublisherIndicators() {
+ petCards().forEach((card) => {
+ const status = petCardStatus(card);
+ const badge = $(`[data-pet-publish-state="${card.id}"]`);
+ if (badge) {
+ badge.dataset.state = status.state;
+ badge.textContent = status.label;
+ badge.title = publisher.active_pet_id === card.id && publisher.last_error
+ ? String(publisher.last_error)
+ : status.label;
+ }
+ const article = $(`[data-pet-card-id="${card.id}"]`);
+ if (article) article.dataset.petState = status.state;
+ });
+ }
+
function nfcReaderNote(reader = nfcSnapshot.reader) {
if (reader?.online) {
const firmware = reader.firmware ? ` · PN532 ${reader.firmware}` : "";
@@ -3320,6 +3606,7 @@
const reader = nfcSnapshot.reader || {};
const card = nfcSnapshot.card;
const linkedDevice = linkedDeviceForCard(card?.uid);
+ const linkedPet = linkedPetForCard(card?.uid);
const deviceGroups = infraredDeviceGroups().filter((group) => group.deviceId);
status.textContent = nfcReaderNote(reader);
badge.textContent = reader.online ? "READER READY" : "READER OFFLINE";
@@ -3330,11 +3617,21 @@
body.innerHTML = `读卡器尚未就绪${escapeHtml(nfcReaderNote(reader))}
`;
} else if (!card?.uid) {
stage.dataset.state = "waiting";
- body.innerHTML = `请将 NFC 卡靠近读卡器让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到一个已配置的设备 ID。
`;
- } else if (!linkedDevice) {
+ body.innerHTML = `请将 NFC 卡靠近读卡器让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到宠物卡片或已配置的设备 ID。
`;
+ } else if (!linkedDevice && !linkedPet) {
stage.dataset.state = "new-card";
- body.innerHTML = `检测到未绑定 NFC${escapeHtml(card.uid)}请选择设备 ID;控制对象名称仅作为调试辅助说明。
+ body.innerHTML = `检测到未绑定 NFC${escapeHtml(card.uid)}请选择宠物卡片;也可继续关联已有红外设备 ID。
+ ${renderNfcPetBinding(null)}
${deviceGroups.length ? `绑定对象(设备 ID)关联后,该设备的全部命令默认允许执行,可再逐项取消。 ` : `暂无可绑定的设备 ID请先在 GSCU1B TCP 设置中为已学习红外槽位填写设备 ID。
`}`;
+ } else if (linkedPet && !linkedDevice) {
+ stage.dataset.state = "recognized";
+ body.innerHTML = `
+
+
NFC UID · ${escapeHtml(card.uid)}${escapeHtml(linkedPet.pet_name)}宠物动作:${escapeHtml(linkedPet.action)} · 本次识别在 1 秒内发送 3 帧 n7/pet
+
+
+ ${renderNfcPetBinding(linkedPet)}
+ ${deviceGroups.length ? `继续关联红外设备 ID(可选)宠物消息与红外设备授权相互独立,不会覆盖宠物卡片内容。 ` : ""}`;
} else {
const group = deviceGroups.find((item) => item.deviceId === linkedDevice.id);
const capabilities = configuredCapabilities(linkedDevice.id);
@@ -3346,6 +3643,7 @@
NFC UID · ${escapeHtml(card.uid)}${escapeHtml(linkedDevice.id)}控制对象:${escapeHtml(group?.objectNames.join("、") || linkedDevice.name || "无辅助描述")} · ${enabledCount}/${capabilities.length} 条命令已授权
+ ${renderNfcPetBinding(linkedPet)}
允许此 NFC 执行的命令
${enabledCount}/${capabilities.length} 已选择
${capabilities.length ? `${capabilities.map((capability) => ``).join("")}
` : `该设备 ID 暂无有效命令槽位可能已删除或改绑到其他设备 ID,请重新检查红外设置。
`}
复选框状态会持久化为自然语言执行白名单。
`;
@@ -3363,7 +3661,7 @@
- `;
+ `;
document.body.append(dialog);
}
@@ -3446,6 +3744,7 @@
function applyNfcSnapshot(value, { announce = true } = {}) {
if (!value || typeof value !== "object") return;
+ if (value.pet_cards) applyPetCardSnapshot(value.pet_cards);
const revision = Number(value.revision ?? -1);
if (revision === nfcSnapshot.revision) return;
if (Array.isArray(value.legacy_profiles) && value.legacy_profiles.length) {
@@ -3464,13 +3763,25 @@
const currentUid = nfcSnapshot.card?.uid || null;
if (currentUid && currentUid !== previousUid) {
const device = linkedDeviceForCard(currentUid);
+ const pet = linkedPetForCard(currentUid);
if (device) {
state.selectedDeviceByType[device.type] = device.id;
saveState();
- if (announce) showToast(`已识别设备 ID“${device.id}”。`, "success");
+ if (announce) {
+ showToast(
+ pet
+ ? `已识别宠物“${pet.pet_name}”和设备 ID“${device.id}”。`
+ : `已识别设备 ID“${device.id}”。`,
+ "success",
+ );
+ }
void syncPeripheralCatalog(device);
+ } else if (pet) {
+ if (announce) {
+ showToast(`已识别宠物“${pet.pet_name}”,将在 1 秒内发送 3 帧 n7/pet。`, "success");
+ }
} else if (announce) {
- showToast("检测到未绑定 NFC,请选择一个设备 ID。", "success");
+ showToast("检测到未绑定 NFC,请选择宠物卡片或设备 ID。", "success");
}
}
renderNfcDialog();
@@ -3510,8 +3821,32 @@
function bindUnifiedNfcEvents(mount) {
mount.addEventListener("click", (event) => {
- if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog();
- if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
+ const addPet = event.target.closest("[data-pet-add]");
+ const petArticle = event.target.closest("[data-pet-card-id]");
+ const savePet = event.target.closest("[data-pet-save]");
+ const deletePet = event.target.closest("[data-pet-delete]");
+ if (addPet) void createPetCard(addPet);
+ else if (savePet) void savePetCard(petArticle, savePet);
+ else if (deletePet) void deletePetCard(petArticle, deletePet);
+ else if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog();
+ else if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
+ });
+ mount.addEventListener("input", (event) => {
+ const field = event.target.closest("[data-pet-field]");
+ const article = field?.closest("[data-pet-card-id]");
+ if (!field || !article) return;
+ const current = petCardDrafts.get(article.dataset.petCardId)
+ || petCards().find((card) => card.id === article.dataset.petCardId)
+ || {};
+ petCardDrafts.set(article.dataset.petCardId, {
+ ...current,
+ [field.dataset.petField]: field.value,
+ });
+ const title = $("header strong", article);
+ if (field.dataset.petField === "pet_name" && title) {
+ title.textContent = field.value.trim() || "未命名宠物";
+ }
+ updatePetPublisherIndicators();
});
bindGscuTcpDialogEvents();
const dialog = $("#extensionNfcDialog");
@@ -3519,8 +3854,15 @@
if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog");
if (event.target.closest("[data-nfc-bind-existing]")) bindCurrentNfcToExisting();
if (event.target.closest("[data-nfc-unbind]")) unbindCurrentNfc();
+ if (event.target.closest("[data-nfc-bind-pet]")) void bindCurrentNfcToPet();
+ const unbindPet = event.target.closest("[data-nfc-unbind-pet]");
+ if (unbindPet) void unbindPetCard(unbindPet.dataset.nfcUnbindPet);
+ const addPet = event.target.closest("[data-pet-add]");
+ if (addPet) void createPetCard(addPet);
});
dialog.addEventListener("change", (event) => {
+ const petSelect = event.target.closest("[data-nfc-pet-select]");
+ if (petSelect) selectedPetCardId = petSelect.value;
const capability = event.target.closest("[data-nfc-capability-enabled]");
if (capability) {
updateNfcCapabilityAuthorization(
@@ -3588,6 +3930,11 @@
void migrateLegacyProfiles(embeddedNfc.legacy_bridge_addresses);
}
}
+ if (health.pet_cards) applyPetCardSnapshot(health.pet_cards, { render: false });
+ if (document.activeElement?.closest?.("#vehicleExtensionSync [data-pet-field]")) {
+ updatePetPublisherIndicators();
+ return;
+ }
const physicalConnected = health.transport !== "simulation"
&& Boolean(health.connected || health.peer_connected);
const listening = Boolean(health.listening);
@@ -3605,6 +3952,7 @@
const nfcCard = nfcSnapshot.card;
const sensedCardUid = normalizeCardUid(nfcCard?.uid);
const nfcDevice = linkedDeviceForCard(sensedCardUid);
+ const nfcPet = linkedPetForCard(sensedCardUid);
const activeCatalog = sensedCardUid
? mergedCatalog.filter((item) => (
normalizeCardUid(item.card_uid) === sensedCardUid
@@ -3630,8 +3978,16 @@
const nfcCapabilities = nfcDevice
? configuredCapabilities(nfcDevice.id).filter((item) => item.enabled !== false)
: [];
- const statusLabel = devices.length
- ? "已感应 NFC 外设"
+ const statusLabel = nfcPet
+ ? petCardSnapshot.publisher?.state === "bursting"
+ ? "宠物消息发送中"
+ : petCardSnapshot.publisher?.state === "burst_complete"
+ ? "宠物消息已发送"
+ : petCardSnapshot.publisher?.state === "burst_failed"
+ ? "宠物消息发送失败"
+ : "已感应宠物 NFC"
+ : devices.length
+ ? "已感应 NFC 外设"
: sensedCardUid
? "当前 NFC 暂无已授权命令"
: nfcReader.online
@@ -3650,33 +4006,38 @@
const visibleLastAction = devices.length ? lastAction : null;
const nfcState = !nfcReader.online
? "offline"
- : nfcDevice
+ : nfcDevice || nfcPet
? "recognized"
: nfcCard?.uid
? "new-card"
: "waiting";
- const nfcTitle = nfcDevice
- ? nfcDevice.id
+ const nfcTitle = nfcPet
+ ? `宠物 · ${nfcPet.pet_name}`
+ : nfcDevice
+ ? nfcDevice.id
: nfcCard?.uid
? "检测到未绑定 NFC"
: nfcReader.online
? "等待 NFC 卡靠近"
: "NFC 读卡器未连接";
- const nfcNote = nfcDevice
- ? `${nfcCapabilities.length} 条命令已授权 · ${nfcCard.uid}`
+ const nfcNote = nfcPet
+ ? `${nfcPet.action} · ${petCardSnapshot.publisher?.topic || "n7/pet"} · 3 帧 / 1 秒`
+ : nfcDevice
+ ? `${nfcCapabilities.length} 条命令已授权 · ${nfcCard.uid}`
: nfcCard?.uid
? `${nfcCard.uid} · 点击关联设备 ID`
: nfcReaderNote(nfcReader);
mount.dataset.hasDevices = String(devices.length > 0);
+ mount.dataset.hasPets = String(petCards().length > 0);
mount.innerHTML = `
外设与扩展能力
- ${statusLabel}
+ ${statusLabel}
-
+
${escapeHtml(nfcTitle)}${escapeHtml(nfcNote)}
${nfcCapabilities.length ? `
${nfcCapabilities.slice(0, 6).map((capability) => `${escapeHtml(capability.name)}`).join("")}
` : ""}
-
+
@@ -3684,6 +4045,7 @@
${String(health.transport || "GSCU").toUpperCase()}
+ ${renderPetCardRegion()}
${devices.length ? devices.map((device) => `
@@ -3691,7 +4053,7 @@
${device.actions.slice(0, 8).map((action) => `${escapeHtml(action)}`).join("")}
`).join("") : `
-
${sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}${sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}
+
${nfcPet ? "当前 NFC 已绑定宠物卡片" : sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}${nfcPet ? "本次识别完成 3 帧后保持静默,移开再靠近才会再次发送" : sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}
`}