feat(n7web): add pet NFC workflow and glass UI

This commit is contained in:
Miayui
2026-09-14 15:16:02 +08:00
parent 812e67422d
commit aa6f59d3a6
20 changed files with 2724 additions and 102 deletions
+30
View File
@@ -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 与硬件执行权限仍由服务端安全门禁控制。
当前页面包括:
+45 -43
View File
@@ -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);
+581
View File
@@ -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);
}
}
+5 -4
View File
@@ -4,18 +4,19 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="oklch(0.57 0.19 258)" />
<meta name="theme-color" content="#17182b" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/app-icon-192.png" />
<title>511工程-智能座舱</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-23" />
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-69" />
<link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-43" />
<link rel="stylesheet" href="/self-extension.css?v=pet-acrylic-48" />
<link rel="stylesheet" href="/speech-input.css?v=sensevoice-7" />
<link rel="stylesheet" href="/assistant-chat.css?v=codex-assistant-28" />
<link rel="stylesheet" href="/energy-monitor.css?v=energy-monitor-4" />
<link rel="stylesheet" href="/ui-consistency.css?v=spatial-ui-4" />
<link rel="stylesheet" href="/codepen-glass-theme.css?v=creative-cloud-1" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/vue/3.5.21/vue.runtime.global.prod.js" defer></script>
<script src="/vue-ui.js?v=vue-presentation-3" defer></script>
@@ -23,12 +24,12 @@
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-2" defer></script>
<script src="/app.js?v=dual-seat-135" defer></script>
<script src="/app.js?v=dual-seat-136" defer></script>
<script src="/speech-input.js?v=sensevoice-13" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=local-first-70" defer></script>
<script src="/self-extension.js?v=gscu-tcp-44" defer></script>
<script src="/self-extension.js?v=pet-acrylic-48" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-32" defer></script>
<script src="/energy-monitor.js?v=energy-monitor-4" defer></script>
</head>
+544
View File
@@ -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%;
}
}
+384 -19
View File
@@ -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 `<section class="v2-nfc-pet-binding" data-state="${escapeHtml(status.state)}">
<div><span><i data-lucide="paw-print"></i></span><div><small>已绑定宠物卡片</small><strong>${escapeHtml(linkedPet.pet_name)}</strong><p>${escapeHtml(linkedPet.action)} · 本次识别仅发送 3 帧</p></div></div>
<div><code>${escapeHtml(linkedPet.nfc_id)}</code><b>${escapeHtml(status.label)}</b><button type="button" data-nfc-unbind-pet="${escapeHtml(linkedPet.id)}">解绑 NFC</button></div>
</section>`;
}
if (!petCards().length) {
return `<div class="v2-nfc-no-devices v2-nfc-no-pets"><i data-lucide="paw-print"></i><div><strong>暂无宠物卡片</strong><span>请先返回右侧“宠物卡片”区域添加、编辑并保存卡片。</span></div></div>`;
}
const bindableCards = petCards().filter(petCardCanBind);
if (!bindableCards.length) {
return `<div class="v2-nfc-no-devices v2-nfc-no-pets"><i data-lucide="save"></i><div><strong>暂无可绑定的已保存卡片</strong><span>请先在右侧完成宠物名与动作编辑并点击保存,再打开此弹窗绑定。</span></div></div>`;
}
const selected = bindableCards.some((card) => card.id === selectedPetCardId)
? selectedPetCardId
: bindableCards[0].id;
return `<div class="v2-nfc-existing v2-nfc-pet-existing"><span>绑定宠物卡片</span><div><select id="extensionNfcExistingPet" data-nfc-pet-select>${bindableCards.map((card) => `<option value="${escapeHtml(card.id)}" ${card.id === selected ? "selected" : ""}>${escapeHtml(card.pet_name)} — ${escapeHtml(card.action)}${card.nfc_id ? ` — 当前已绑定 ${escapeHtml(card.nfc_id)}` : ""}</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-pet>关联宠物卡片</button></div><small>卡片 ID 由当前读卡器中的 NFC 自动写入,不能手动编辑;同一 NFC 只绑定一张宠物卡片。</small></div>`;
}
function renderPetCardRegion() {
const publisher = petCardSnapshot.publisher || {};
const cards = petCards();
return `<section class="v2-pet-card-region" aria-labelledby="v2PetCardTitle">
<header><div><span><i data-lucide="paw-print"></i></span><h4 id="v2PetCardTitle">宠物卡片</h4></div><button type="button" data-pet-add><i data-lucide="plus"></i><span>添加宠物</span></button></header>
<div class="v2-pet-card-list">
${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 `<article class="v2-pet-card" data-pet-card-id="${escapeHtml(card.id)}" data-pet-state="${escapeHtml(status.state)}">
<header><span><i data-lucide="paw-print"></i></span><strong>${escapeHtml(draft.pet_name || card.pet_name)}</strong><b data-pet-publish-state="${escapeHtml(card.id)}" data-state="${escapeHtml(status.state)}" title="${escapeHtml(activeError || status.label)}">${escapeHtml(status.label)}</b><button type="button" data-pet-delete aria-label="删除宠物卡片" title="删除宠物卡片"><i data-lucide="trash-2"></i></button></header>
<div class="v2-pet-card-fields">
<label><span>宠物名</span><input type="text" maxlength="64" value="${escapeHtml(draft.pet_name || "")}" data-pet-field="pet_name" autocomplete="off" /></label>
<label><span>宠物动作</span><textarea maxlength="160" rows="2" data-pet-field="action">${escapeHtml(draft.action || "")}</textarea></label>
<label class="v2-pet-nfc-field"><span>卡片 ID</span><div><output>${card.nfc_id ? escapeHtml(card.nfc_id) : "未绑定"}</output></div></label>
</div>
<footer><button type="button" data-pet-save><i data-lucide="save"></i><span>保存修改</span></button></footer>
</article>`;
}).join("") : `<div class="v2-pet-card-empty"><i data-lucide="paw-print"></i><strong>还没有宠物卡片</strong><button type="button" data-pet-add>添加第一张</button></div>`}
</div>
</section>`;
}
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 = `<div class="v2-nfc-waiting offline"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><strong>读卡器尚未就绪</strong><p>${escapeHtml(nfcReaderNote(reader))}</p></div></div>`;
} else if (!card?.uid) {
stage.dataset.state = "waiting";
body.innerHTML = `<div class="v2-nfc-waiting"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><strong>请将 NFC 卡靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到一个已配置的设备 ID。</p></div></div>`;
} else if (!linkedDevice) {
body.innerHTML = `<div class="v2-nfc-waiting"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><strong>请将 NFC 卡靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到宠物卡片或已配置的设备 ID。</p></div></div>`;
} else if (!linkedDevice && !linkedPet) {
stage.dataset.state = "new-card";
body.innerHTML = `<div class="v2-nfc-detected-heading"><span><i data-lucide="badge-plus"></i></span><div><small>检测到未绑定 NFC</small><strong>${escapeHtml(card.uid)}</strong><p>请选择设备 ID;控制对象名称仅作为调试辅助说明。</p></div></div>
body.innerHTML = `<div class="v2-nfc-detected-heading"><span><i data-lucide="badge-plus"></i></span><div><small>检测到未绑定 NFC</small><strong>${escapeHtml(card.uid)}</strong><p>请选择宠物卡片;也可继续关联已有红外设备 ID。</p></div></div>
${renderNfcPetBinding(null)}
${deviceGroups.length ? `<div class="v2-nfc-existing"><span>绑定对象(设备 ID)</span><div><select id="extensionNfcExistingDevice" data-nfc-device-select>${deviceGroups.map((group) => `<option value="${escapeHtml(group.deviceId)}">${escapeHtml(group.deviceId)} — ${escapeHtml(group.objectNames.join(" / ") || "无对象描述")} — ${group.commands.length} 条命令</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-existing>关联设备 ID</button></div><small>关联后,该设备的全部命令默认允许执行,可再逐项取消。</small></div>` : `<div class="v2-nfc-no-devices"><i data-lucide="info"></i><div><strong>暂无可绑定的设备 ID</strong><span>请先在 GSCU1B TCP 设置中为已学习红外槽位填写设备 ID。</span></div></div>`}`;
} else if (linkedPet && !linkedDevice) {
stage.dataset.state = "recognized";
body.innerHTML = `<div class="v2-nfc-recognized-heading">
<span><i data-lucide="paw-print"></i></span>
<div><small>NFC UID · ${escapeHtml(card.uid)}</small><strong>${escapeHtml(linkedPet.pet_name)}</strong><p>宠物动作:${escapeHtml(linkedPet.action)} · 本次识别在 1 秒内发送 3 帧 n7/pet</p></div>
<i data-lucide="badge-check"></i>
</div>
${renderNfcPetBinding(linkedPet)}
${deviceGroups.length ? `<div class="v2-nfc-existing"><span>继续关联红外设备 ID(可选)</span><div><select id="extensionNfcExistingDevice" data-nfc-device-select>${deviceGroups.map((group) => `<option value="${escapeHtml(group.deviceId)}">${escapeHtml(group.deviceId)} — ${escapeHtml(group.objectNames.join(" / ") || "无对象描述")} — ${group.commands.length} 条命令</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-existing>关联设备 ID</button></div><small>宠物消息与红外设备授权相互独立,不会覆盖宠物卡片内容。</small></div>` : ""}`;
} else {
const group = deviceGroups.find((item) => item.deviceId === linkedDevice.id);
const capabilities = configuredCapabilities(linkedDevice.id);
@@ -3346,6 +3643,7 @@
<div><small>NFC UID · ${escapeHtml(card.uid)}</small><strong>${escapeHtml(linkedDevice.id)}</strong><p>控制对象:${escapeHtml(group?.objectNames.join("、") || linkedDevice.name || "无辅助描述")} · ${enabledCount}/${capabilities.length} 条命令已授权</p></div>
<i data-lucide="badge-check"></i>
</div>
${renderNfcPetBinding(linkedPet)}
<div class="v2-nfc-capability-heading"><div><strong>允许此 NFC 执行的命令</strong></div><span>${enabledCount}/${capabilities.length} 已选择</span></div>
${capabilities.length ? `<label class="v2-nfc-select-all"><input type="checkbox" data-nfc-select-all ${allEnabled ? "checked" : ""} /><span><strong>全选</strong><small>统一允许或禁止当前设备 ID 下的全部命令</small></span></label><div class="v2-nfc-command-list">${capabilities.map((capability) => `<label class="v2-nfc-command-option"><input type="checkbox" data-nfc-capability-enabled="${escapeHtml(capability.id)}" ${capability.enabled !== false ? "checked" : ""} /><span><strong>${escapeHtml(capability.name)}</strong><small>IR ${String(capability.slot).padStart(3, "0")} · ${escapeHtml((capability.aliases || []).join("、"))}</small></span></label>`).join("")}</div>` : `<div class="v2-nfc-no-devices"><i data-lucide="triangle-alert"></i><div><strong>该设备 ID 暂无有效命令</strong><span>槽位可能已删除或改绑到其他设备 ID,请重新检查红外设置。</span></div></div>`}
<div class="v2-nfc-recognized-actions"><span>复选框状态会持久化为自然语言执行白名单。</span><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
@@ -3363,7 +3661,7 @@
<header><span><i data-lucide="scan-line"></i></span><div><h3>NFC 设备绑定</h3><p id="extensionNfcReaderStatus">正在检查读卡器</p></div><b id="extensionNfcReaderBadge" data-state="offline">READER CHECK</b></header>
<div class="v2-nfc-body" id="extensionNfcBody"></div>
</section>
<footer class="v2-nfc-footnote"><i data-lucide="shield-check"></i><span>NFC UID、设备 ID 与命令授权同步到本机白名单;红外波形仍保存在 GSCU1B 物理槽位。</span></footer>`;
<footer class="v2-nfc-footnote"><i data-lucide="shield-check"></i><span>宠物卡片与 NFC UID 由后端持久化;每次重新识别只在 1 秒内发送 3 帧 n7/pet,随后保持静默。设备 ID 与命令授权逻辑保持不变。</span></footer>`;
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 = `
<header class="v2-extension-sync-heading">
<div><h3>外设与扩展能力</h3><button class="v2-extension-nfc-launch" type="button" data-open-nfc-binding><i data-lucide="scan-line"></i><span>NFC 设备绑定</span></button></div>
<span data-state="${devices.length ? "online" : nfcReader.online ? "listening" : "offline"}"><i></i>${statusLabel}</span>
<span data-state="${nfcPet || devices.length ? "online" : nfcReader.online ? "listening" : "offline"}"><i></i>${statusLabel}</span>
</header>
<section class="v2-nfc-inline" data-state="${nfcState}">
<span class="v2-nfc-inline-icon"><i data-lucide="${nfcDevice ? "badge-check" : "scan-line"}"></i><b></b></span>
<span class="v2-nfc-inline-icon"><i data-lucide="${nfcPet ? "paw-print" : nfcDevice ? "badge-check" : "scan-line"}"></i><b></b></span>
<div><strong>${escapeHtml(nfcTitle)}</strong><p>${escapeHtml(nfcNote)}</p>${nfcCapabilities.length ? `<div>${nfcCapabilities.slice(0, 6).map((capability) => `<i>${escapeHtml(capability.name)}</i>`).join("")}</div>` : ""}</div>
<button type="button" data-open-nfc-binding>${nfcDevice ? "查看授权" : nfcCard?.uid ? "关联设备" : "开始感应"}</button>
<button type="button" data-open-nfc-binding>${nfcPet ? "查看宠物" : nfcDevice ? "查看授权" : nfcCard?.uid ? "关联卡片" : "开始感应"}</button>
</section>
<div class="v2-extension-gateway">
<span><i data-lucide="usb"></i></span>
@@ -3684,6 +4045,7 @@
<b>${String(health.transport || "GSCU").toUpperCase()}</b>
<button type="button" data-open-gscu-tcp><i data-lucide="settings-2"></i><span>TCP 设置</span></button>
</div>
${renderPetCardRegion()}
<div class="v2-extension-device-list">
${devices.length ? devices.map((device) => `
<article class="v2-extension-synced-device">
@@ -3691,7 +4053,7 @@
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div>
</article>`).join("") : `
<div class="v2-extension-empty">
<i data-lucide="${sensedCardUid ? "badge-alert" : "scan-line"}"></i><div><strong>${sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}</strong><span>${sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}</span></div>
<i data-lucide="${nfcPet ? "paw-print" : sensedCardUid ? "badge-alert" : "scan-line"}"></i><div><strong>${nfcPet ? "当前 NFC 已绑定宠物卡片" : sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}</strong><span>${nfcPet ? "本次识别完成 3 帧后保持静默,移开再靠近才会再次发送" : sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}</span></div>
</div>`}
</div>
<footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}">
@@ -3718,6 +4080,9 @@
render();
renderNfcDialog();
startNfcPolling();
void hydratePetCards().catch((error) => {
console.warn("恢复后端宠物卡片失败。", error);
});
void hydratePeripheralCatalog()
.catch((error) => {
console.warn("恢复后端红外能力目录失败,将保留浏览器本地目录。", error);
+5 -4
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v290";
const CACHE_VERSION = "cognitive-agent-pad-v298";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,13 +6,13 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=dual-seat-23",
"/network.js?v=android-pad-2",
"/app.js?v=dual-seat-135",
"/app.js?v=dual-seat-136",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-69",
"/console-v2.js?v=local-first-70",
"/self-extension.css?v=gscu-tcp-43",
"/self-extension.js?v=gscu-tcp-44",
"/self-extension.css?v=pet-acrylic-48",
"/self-extension.js?v=pet-acrylic-48",
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5",
@@ -21,6 +21,7 @@ const APP_SHELL = [
"/energy-monitor.css?v=energy-monitor-4",
"/energy-monitor.js?v=energy-monitor-4",
"/ui-consistency.css?v=spatial-ui-4",
"/codepen-glass-theme.css?v=creative-cloud-1",
"/vue-ui.js?v=vue-presentation-3",
"/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3",