feat(n7web): add pet NFC workflow and glass UI
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user