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
+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);