@@ -476,14 +476,12 @@
saved . infraredCommands = Array . isArray ( saved . infraredCommands )
? saved . infraredCommands . filter ( ( item ) => (
validSlot ( item ? . slot )
&& typeof item ? . name === "string"
&& item . name . trim ( )
) ) . map ( ( item ) => ( {
slot : Number ( item . slot ) ,
name : item . name . trim ( ) . slice ( 0 , 48 ) ,
source : String ( item . source || "tcp" ) ,
learnedAt : item . learnedAt || null ,
} ) )
&& (
String ( item ? . name || "" ) . trim ( )
|| normalizedInfraredNames ( item ? . object _names ? ? item ? . objectNames ) . length
|| normalizedInfraredNames ( item ? . action _names ? ? item ? . actionNames ) . length
)
) ) . map ( normalizedInfraredSlotRecord )
: [ ] ;
saved . selectedDeviceByType = saved . selectedDeviceByType || { } ;
saved . settings = { actionDelayMs : Number ( saved . settings ? . actionDelayMs ) || 520 } ;
@@ -724,7 +722,7 @@
< button class = "v2-extension-primary" type = "submit" id = "extensionGscuTcpApply" > 应用并开始监听 < / b u t t o n >
< / f o r m >
< section class = "v2-gscu-actions" >
< header > < div > < strong > TCP 动作调试 < / s t r o n g > < s m a l l > 与 g s c u 1 b _ c o n t r o l . p y t c p 使 用 相 同 J S O N 报 文 < / s m a l l > < / d i v > < l a b e l > < s p a n > 红 外 槽 位 < / s p a n > < i n p u t i d = " e x t e n s i o n G s c u S l o t " t y p e = " n u m b e r " m i n = " 1 " m a x = " 2 4 8 " v a l u e = " 1 " / > < / l a b e l > < l a b e l > < s p a n > 指 令 名 称 < / s p a n > < i n p u t i d = " e x t e n s i o n G s c u C o m m a n d N a m e " m a x l e n g t h = " 4 8 " p l a c e h o l d e r = " 例 如 : 客 厅 空 调 开 启 " / > < / l a b e l > < / h e a d e r >
< header > < div > < strong > TCP 动作调试 < / s t r o n g > < s m a l l > 设 备 I D 唯 一 标 识 控 制 对 象 ; 对 象 与 动 作 可 填 写 多 个 自 然 语 言 别 名 < / s m a l l > < / d i v > < l a b e l > < s p a n > 红 外 槽 位 < / s p a n > < i n p u t i d = " e x t e n s i o n G s c u S l o t " t y p e = " n u m b e r " m i n = " 1 " m a x = " 2 4 8 " v a l u e = " 1 " / > < / l a b e l > < l a b e l > < s p a n > 设 备 I D < / s p a n > < i n p u t i d = " e x t e n s i o n G s c u D e v i c e I d " m a x l e n g t h = " 6 4 " p l a c e h o l d e r = " t o y - l i g h t - 0 0 1 " / > < / l a b e l > < l a b e l > < s p a n > 控 制 对 象 名 称 < / s p a n > < i n p u t i d = " e x t e n s i o n G s c u O b j e c t N a m e s " m a x l e n g t h = " 2 4 0 " p l a c e h o l d e r = " 闪 烁 灯 、 闪 光 灯 、 闪 灯 玩 具 " / > < / l a b e l > < l a b e l > < s p a n > 执 行 动 作 < / s p a n > < i n p u t i d = " e x t e n s i o n G s c u A c t i o n N a m e s " m a x l e n g t h = " 2 4 0 " p l a c e h o l d e r = " 打 开 、 开 启 、 启 动 " / > < / l a b e l > < / h e a d e r >
< div class = "v2-gscu-action-grid" >
< button type = "button" data - gscu - action = "info" > 查询设备 < / b u t t o n >
< button type = "button" data - gscu - action = "learn" > 开始学习 < / b u t t o n >
@@ -744,8 +742,8 @@
< label > < span > 恢复出厂确认 < /span><input id="extensionGscuDangerConfirm" autocomplete="off" placeholder="恢复出厂请输入 RESET_DEVICE" / > < / l a b e l >
< div > < button class = "danger" type = "button" data - gscu - action = "erase" > 擦除全部红外码 < / b u t t o n > < b u t t o n c l a s s = " d a n g e r " t y p e = " b u t t o n " d a t a - g s c u - a c t i o n = " r e s e t " > 恢 复 出 厂 设 置 < / b u t t o n > < / d i v >
< / d e t a i l s >
< section class = "v2-gscu-command-catalog" > < header > < strong > 已学习红外信号 < / s t r o n g > < s m a l l > 槽 位 与 名 称 保 存 在 本 机 数 据 库 , 并 按 槽 位 号 排 列 < / s m a l l > < / h e a d e r > < d i v i d = " e x t e n s i o n G s c u N a m e d C o m m a n d s " > < / d i v > < / s e c t i o n >
< pre id = "extensionGscuTcpResult" > 等待操作 < / p r e >
< section class = "v2-gscu-command-catalog" > < header > < strong > 已学习红外信号 < / s t r o n g > < s m a l l > 按 设 备 I D 分 组 ; 对 象 名 称 用 于 自 然 语 言 识 别 和 调 试 , 动 作 请 包 含 结 果 值 , 如 “ 切 换 为 蓝 色 ” < / s m a l l > < / h e a d e r > < d i v i d = " e x t e n s i o n G s c u N a m e d C o m m a n d s " > < / d i v > < / s e c t i o n >
< details class = "v2-gscu-result" id = "extensionGscuTcpResultDetails" > < summary > JSON 调试结果 < / s u m m a r y > < p r e i d = " e x t e n s i o n G s c u T c p R e s u l t " > 等 待 操 作 < / p r e > < / d e t a i l s >
< / s e c t i o n >
< / d i a l o g >
< div class = "v2-extension-toast-stack" id = "extensionToastStack" aria - live = "polite" > < / d i v > ` ;
@@ -817,7 +815,7 @@
$ ( "b" , status ) . textContent = connected ? "READY" : listening ? "WAIT" : "OFFLINE" ;
if ( refreshControls ) {
renderGscuNamedCommands ( ) ;
syncGscuCommandNameFromSlot ( ) ;
syncGscuCommandNamesFromSlot ( ) ;
}
}
@@ -826,24 +824,143 @@
return [ ... state . infraredCommands ] . sort ( ( left , right ) => Number ( left . slot ) - Number ( right . slot ) ) ;
}
function normalizedInfraredNames ( value ) {
const values = Array . isArray ( value )
? value
: String ( value || "" ) . split ( /[,,、;; |]+/ ) ;
return [ ... new Set ( values
. map ( ( item ) => String ( item || "" ) . trim ( ) . slice ( 0 , 48 ) )
. filter ( Boolean ) ) ] . slice ( 0 , 24 ) ;
}
function normalizedInfraredDeviceId ( value ) {
return String ( value || "" ) . trim ( ) . slice ( 0 , 64 ) ;
}
function normalizedInfraredSlotRecord ( item ) {
const objectNames = normalizedInfraredNames ( item . object _names ? ? item . objectNames ) ;
const actionNames = normalizedInfraredNames ( item . action _names ? ? item . actionNames ) ;
const legacyName = String ( item . name || "" ) . trim ( ) . slice ( 0 , 48 ) ;
return {
slot : Number ( item . slot ) ,
name : String ( item . name || "" ) . trim ( ) . slice ( 0 , 48 ) ,
deviceId : normalizedInfraredDeviceId ( item . device _id ? ? item . deviceId ) ,
name : legacyName || ` ${ objectNames [ 0 ] || "未命名对象" } · ${ actionNames [ 0 ] || "未命名动作" } ` ,
objectNames ,
actionNames : actionNames . length || objectNames . length ? actionNames : normalizedInfraredNames ( legacyName ) ,
source : String ( item . source || "tcp" ) ,
learnedAt : item . learned _at || item . learnedAt || null ,
updatedAt : item . updated _at || item . updatedAt || null ,
} ;
}
function infraredDeviceGroups ( ) {
const groups = new Map ( ) ;
namedInfraredCommands ( ) . forEach ( ( command ) => {
const deviceId = normalizedInfraredDeviceId ( command . deviceId ) ;
const key = deviceId || "" ;
if ( ! groups . has ( key ) ) {
groups . set ( key , { deviceId , objectNames : [ ] , commands : [ ] } ) ;
}
const group = groups . get ( key ) ;
group . commands . push ( command ) ;
group . objectNames = normalizedInfraredNames ( [
... group . objectNames ,
... command . objectNames ,
] ) ;
} ) ;
return [ ... groups . values ( ) ] . sort ( ( left , right ) => {
if ( ! left . deviceId ) return 1 ;
if ( ! right . deviceId ) return - 1 ;
return left . deviceId . localeCompare ( right . deviceId ) ;
} ) ;
}
function reconcileDevicesAndCapabilitiesFromSlots ( ) {
const configuredKeys = new Set ( ) ;
infraredDeviceGroups ( ) . filter ( ( group ) => group . deviceId ) . forEach ( ( group ) => {
let device = state . devices . find ( ( item ) => item . id === group . deviceId ) ;
if ( ! device ) {
device = {
id : group . deviceId ,
name : group . objectNames [ 0 ] || group . deviceId ,
type : "other" ,
aliases : group . objectNames . length ? group . objectNames : [ group . deviceId ] ,
toyNumber : null ,
cardUid : null ,
createdAt : new Date ( ) . toISOString ( ) ,
source : "infrared_slot_catalog" ,
} ;
state . devices . push ( device ) ;
} else {
device . name = group . objectNames [ 0 ] || device . name || group . deviceId ;
device . aliases = group . objectNames . length ? group . objectNames : [ device . name ] ;
}
group . commands . forEach ( ( command ) => {
const key = ` ${ group . deviceId } \u 0000 ${ command . slot } ` ;
configuredKeys . add ( key ) ;
let capability = state . capabilities . find ( ( item ) => (
item . deviceId === group . deviceId && Number ( item . slot ) === Number ( command . slot )
) ) ;
if ( ! capability ) {
capability = {
id : ` ir-slot: ${ group . deviceId } : ${ command . slot } ` ,
deviceId : group . deviceId ,
key : ` slot_ ${ command . slot } ` ,
createdAt : new Date ( ) . toISOString ( ) ,
enabled : true ,
} ;
state . capabilities . push ( capability ) ;
}
Object . assign ( capability , {
deviceId : group . deviceId ,
name : command . actionNames [ 0 ] || command . name ,
aliases : command . actionNames . length ? command . actionNames : [ command . name ] ,
category : "infrared_slot" ,
risk : capability . risk || "low" ,
slot : Number ( command . slot ) ,
status : "learned" ,
verified : true ,
enabled : capability . enabled !== false ,
signalSource : "slot_catalog" ,
} ) ;
} ) ;
} ) ;
state . capabilities = state . capabilities . filter ( ( capability ) => {
if ( ! [ "slot_catalog" , "backend_catalog" ] . includes ( capability . signalSource ) ) return true ;
return configuredKeys . has ( ` ${ capability . deviceId } \u 0000 ${ Number ( capability . slot ) } ` ) ;
} ) ;
}
function configuredCapabilities ( deviceId = null ) {
const configuredKeys = new Set (
namedInfraredCommands ( )
. filter ( ( command ) => command . deviceId )
. map ( ( command ) => ` ${ command . deviceId } \u 0000 ${ Number ( command . slot ) } ` ) ,
) ;
return state . capabilities . filter ( ( item ) => (
( ! deviceId || item . deviceId === deviceId )
&& item . status === "learned"
&& item . verified
&& validSlot ( item . slot )
&& configuredKeys . has ( ` ${ item . deviceId } \u 0000 ${ Number ( item . slot ) } ` )
) ) ;
}
function replaceInfraredSlotCatalog ( items ) {
state . infraredCommands = ( Array . isArray ( items ) ? items : [ ] )
. filter ( ( item ) => validSlot ( item ? . slot ) && String ( item ? . name || "" ) . trim ( ) )
. filter ( ( item ) => validSlot ( item ? . slot ) && (
String ( item ? . name || "" ) . trim ( )
|| normalizedInfraredNames ( item ? . object _names ? ? item ? . objectNames ) . length
|| normalizedInfraredNames ( item ? . action _names ? ? item ? . actionNames ) . length
) )
. map ( normalizedInfraredSlotRecord )
. sort ( ( left , right ) => left . slot - right . slot ) ;
reconcileDevicesAndCapabilitiesFromSlots ( ) ;
saveState ( ) ;
renderGscuNamedCommands ( ) ;
syncGscuCommandNameFromSlot ( ) ;
syncGscuCommandNamesFromSlot ( ) ;
if ( $ ( "#extensionNfcDialog" ) ) renderNfcDialog ( ) ;
renderUnifiedExtensionView ? . ( ) ;
}
async function refreshInfraredSlotCatalog ( { migrateLegacy = true } = { } ) {
@@ -857,7 +974,16 @@
if ( missing . length ) {
await Promise . all ( missing . map ( ( item ) => api ( ` gscu/slots/ ${ item . slot } ` , {
method : "PUT" ,
body : JSON . stringify ( { name : item . name , source : item . source || "browser-migration" } ) ,
body : JSON . stringify (
item . deviceId && item . objectNames ? . length && item . actionNames ? . length
? {
device _id : item . deviceId ,
object _names : item . objectNames ,
action _names : item . actionNames ,
source : item . source || "browser-migration" ,
}
: { name : item . name , source : item . source || "browser-migration" } ,
) ,
} ) ) ) ;
result = await api ( "gscu/slots" ) ;
}
@@ -866,43 +992,74 @@
return namedInfraredCommands ( ) ;
}
async function upsertNamedInfraredCommand ( slot , name , source = "tcp" ) {
async function upsertNamedInfraredCommand (
slot ,
deviceId ,
objectNames ,
actionNames ,
source = "tcp" ,
) {
const normalizedSlot = Number ( slot ) ;
const normalizedName = String ( name || "" ) . trim ( ) . slice ( 0 , 48 ) ;
const normalizedDeviceId = normalizedInfraredDeviceId ( deviceId ) ;
const normalizedObjects = normalizedInfraredNames ( objectNames ) ;
const normalizedActions = normalizedInfraredNames ( actionNames ) ;
if ( ! validSlot ( normalizedSlot ) ) throw new Error ( "红外槽位范围是 1– 248。" ) ;
if ( ! normalizedName ) throw new Error ( "学习红外指令前需要填写指令名称 。" ) ;
if ( ! normalizedDeviceId ) throw new Error ( "请填写唯一的设备 ID 。" ) ;
if ( ! /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ . test ( normalizedDeviceId ) ) {
throw new Error ( "设备 ID 必须以字母或数字开头,且只能包含字母、数字、点、下划线、冒号或连字符。" ) ;
}
if ( ! normalizedObjects . length ) throw new Error ( "请至少填写一个控制对象名称。" ) ;
if ( ! normalizedActions . length ) throw new Error ( "请至少填写一个执行动作。" ) ;
const result = await api ( ` gscu/slots/ ${ normalizedSlot } ` , {
method : "PUT" ,
body : JSON . stringify ( { name : normalizedName , source } ) ,
body : JSON . stringify ( {
device _id : normalizedDeviceId ,
object _names : normalizedObjects ,
action _names : normalizedActions ,
source ,
} ) ,
} ) ;
const value = normalizedInfraredSlotRecord ( result . slot ) ;
const existing = state . infraredCommands . find ( ( item ) => item . slot === normalizedSlot ) ;
if ( existing ) Object . assign ( existing , value ) ;
else state . infraredCommands . push ( value ) ;
reconcileDevicesAndCapabilitiesFromSlots ( ) ;
const boundDevice = state . devices . find ( ( item ) => item . id === value . deviceId ) ;
saveState ( ) ;
renderGscuNamedCommands ( ) ;
if ( boundDevice ) {
renderAll ( ) ;
await syncPeripheralCatalog ( boundDevice ) ;
}
return value ;
}
function renderGscuNamedCommands ( ) {
const container = $ ( "#extensionGscuNamedCommands" ) ;
if ( ! container ) return ;
const commands = namedInfraredCommands ( ) ;
container . innerHTML = commands . length
? commands . map ( ( command ) => ` <article data-gscu-command-slot=" ${ command . slot } "><header><span>红外槽位</span><b>IR ${ String ( command . slot ) . padStart ( 3 , "0" ) } </b></header><label><span>信号名称</span><input maxlength="48 " value="${ escapeHtml ( command . name ) } " aria-label="IR ${ command . slot } 信号 名称" /></label><footer><button type="button" data-gscu-named-emit=" ${ command . slot } ">发射</button><button type="button" data-gscu-named-save=" ${ command . slot } ">保存名称 </button><button class="danger" type="button" data-gscu-named-remove=" ${ command . slot } ">删除本地记录</button></footer></article> ` ) . join ( "" )
: "<p>学习成功后将在这里显示编号与指令名称 。</p>" ;
const groups = infraredDeviceGroups ( ) ;
container . innerHTML = groups . length
? groups . map ( ( group ) => ` <section class="v2-gscu-device-group" data-device-id=" ${ escapeHtml ( group . deviceId ) } "><header><div><small> ${ group . deviceId ? "DEVICE ID" : "UNASSIGNED" } </small><strong> ${ escapeHtml ( group . deviceId || "未分配设备 ID" ) } </strong><span> ${ escapeHtml ( group . objectNames . join ( "、" ) || "尚无控制对象描述" ) } </span></div><b> ${ group . commands . length } 条命令</b></header><div class="v2-gscu-device-command-grid"> ${ group . commands . map ( ( command ) => ` <article data-gscu-command-slot=" ${ command . slot } "><header><span>红外槽位</span><b>IR ${ String ( command . slot ) . padStart ( 3 , "0" ) } </b></header><label><span>设备 ID</span><input data-gscu-device-id maxlength="64" value=" ${ escapeHtml ( command . deviceId ) } " placeholder="唯一设备 ID" aria-label="IR ${ command . slot } 设备 ID" /></label><label><span>控制对象名称 / 别名</span><input data-gscu-object-names maxlength="240 " value="${ escapeHtml ( command . objectNames . join ( "、" ) ) } " placeholder="用逗号分隔多个名称 " aria-label="IR ${ command . slot } 控制对象 名称" /></label><label><span>执行动作 / 别名</span><input data-gscu-action-names maxlength="240" value=" ${ escapeHtml ( command . actionNames . join ( "、" ) ) } " placeholder="颜色等动作需包含结果值" aria-label="IR ${ command . slot } 执行动作" /></label>< footer><button type="button" data-gscu-named-emit="${ command . slot } ">发射</button><button type="button" data-gscu-named-save=" ${ command . slot } ">保存设置 </button><button class="danger" type="button" data-gscu-named-remove=" ${ command . slot } ">删除本地记录</button></footer></article> ` ) . join ( "" ) } </div></section> ` ) . join ( "" )
: "<p>学习成功后将在这里按设备 ID 显示槽位、控制对象和执行动作 。</p>" ;
}
function syncGscuCommandNameFromSlot ( ) {
function syncGscuCommandNamesFromSlot ( ) {
const slot = Number ( $ ( "#extensionGscuSlot" ) ? . value ) ;
const command = namedInfraredCommands ( ) . find ( ( item ) => item . slot === slot ) ;
if ( $ ( "#extensionGscuCommandName " ) ) {
$ ( "#extensionGscuCommandName " ) . value = command ? . name || "" ;
if ( $ ( "#extensionGscuDeviceId " ) ) {
$ ( "#extensionGscuDeviceId " ) . value = command ? . deviceId || "" ;
}
if ( $ ( "#extensionGscuObjectNames" ) ) {
$ ( "#extensionGscuObjectNames" ) . value = command ? . objectNames . join ( "、" ) || "" ;
}
if ( $ ( "#extensionGscuActionNames" ) ) {
$ ( "#extensionGscuActionNames" ) . value = command ? . actionNames . join ( "、" ) || "" ;
}
}
function openGscuTcpDialog ( health = healthState || { } ) {
renderGscuTcpDialog ( health ) ;
$ ( "#extensionGscuTcpResultDetails" ) ? . removeAttribute ( "open" ) ;
openDialog ( "extensionGscuTcpDialog" ) ;
void refreshInfraredSlotCatalog ( ) . catch ( ( error ) => {
showToast ( ` 读取本地红外槽位数据库失败: ${ error . message } ` , "error" ) ;
@@ -945,7 +1102,9 @@
async function runGscuTcpAction ( button ) {
const action = button . dataset . gscuAction ;
const slot = Number ( $ ( "#extensionGscuSlot" ) . value ) ;
const commandName = $ ( "#extensionGscuCommandName " ) . value . trim ( ) ;
const deviceId = normalizedInfraredDeviceId ( $ ( "#extensionGscuDeviceId " ) . value ) ;
const objectNames = normalizedInfraredNames ( $ ( "#extensionGscuObjectNames" ) . value ) ;
const actionNames = normalizedInfraredNames ( $ ( "#extensionGscuActionNames" ) . value ) ;
const payload = { } ;
if ( [ "learn" , "emit" , "read" , "write" ] . includes ( action ) ) payload . slot = slot ;
if ( action === "write" ) payload . ir _data = $ ( "#extensionGscuIrData" ) . value . trim ( ) ;
@@ -968,9 +1127,19 @@
payload . confirm = "ERASE_ALL" ;
}
if ( action === "reset" ) payload . confirm = confirmation ;
if ( action === "learn" && ! commandName ) {
showToast ( "请输入该红外编号 对应的指令名称 。" , "error" ) ;
$ ( "#extensionGscuCommandName " ) . focus ( ) ;
if ( action === "learn" && ! deviceId ) {
showToast ( "请输入该红外槽位 对应的唯一设备 ID 。" , "error" ) ;
$ ( "#extensionGscuDeviceId " ) . focus ( ) ;
return ;
}
if ( action === "learn" && ! objectNames . length ) {
showToast ( "请输入该红外槽位对应的控制对象名称。" , "error" ) ;
$ ( "#extensionGscuObjectNames" ) . focus ( ) ;
return ;
}
if ( action === "learn" && ! actionNames . length ) {
showToast ( "请输入该红外槽位对应的执行动作。" , "error" ) ;
$ ( "#extensionGscuActionNames" ) . focus ( ) ;
return ;
}
button . disabled = true ;
@@ -1006,7 +1175,7 @@
: "学习指令已下发,但槽位回读为空;请重新学习并短按遥控器按键。" ,
) ;
}
await upsertNamedInfraredCommand ( slot , commandName , "tcp" ) ;
await upsertNamedInfraredCommand ( slot , deviceId , objectNames , actionNames , "tcp" ) ;
result = {
... result ,
learning _capture _confirmed : true ,
@@ -1051,7 +1220,7 @@
if ( ! dialog || dialog . dataset . bound === "true" ) return ;
dialog . dataset . bound = "true" ;
$ ( "#extensionGscuTcpForm" ) . addEventListener ( "submit" , configureGscuTcp ) ;
$ ( "#extensionGscuSlot" ) . addEventListener ( "change" , syncGscuCommandNameFromSlot ) ;
$ ( "#extensionGscuSlot" ) . addEventListener ( "change" , syncGscuCommandNamesFromSlot ) ;
dialog . addEventListener ( "click" , ( event ) => {
if ( event . target . closest ( '[data-close-extension-dialog="extensionGscuTcpDialog"]' ) ) {
closeDialog ( "extensionGscuTcpDialog" ) ;
@@ -1068,7 +1237,9 @@
const command = namedInfraredCommands ( ) . find (
( item ) => item . slot === Number ( emit . dataset . gscuNamedEmit ) ,
) ;
$ ( "#extensionGscuCommandName " ) . value = command ? . name || "" ;
$ ( "#extensionGscuDeviceId " ) . value = command ? . deviceId || "" ;
$ ( "#extensionGscuObjectNames" ) . value = command ? . objectNames . join ( "、" ) || "" ;
$ ( "#extensionGscuActionNames" ) . value = command ? . actionNames . join ( "、" ) || "" ;
const emitButton = $ ( "[data-gscu-action=\"emit\"]" , dialog ) ;
if ( emitButton ) void runGscuTcpAction ( emitButton ) ;
return ;
@@ -1079,10 +1250,12 @@
save . disabled = true ;
void upsertNamedInfraredCommand (
Number ( save . dataset . gscuNamedSave ) ,
$ ( "input " , card ) . value ,
$ ( "[data-gscu-device-id] " , card ) . value ,
$ ( "[data-gscu-object-names]" , card ) . value ,
$ ( "[data-gscu-action-names]" , card ) . value ,
"tcp" ,
) . then ( ( value ) => {
showToast ( ` IR ${ value . slot } 已保存为“ ${ value . name } ” 。` , "success" ) ;
showToast ( ` IR ${ value . slot } 的控制对象与执行动作 已保存。 ` , "success" ) ;
} ) . catch ( ( error ) => {
showToast ( error . message , "error" ) ;
} ) . finally ( ( ) => { save . disabled = false ; } ) ;
@@ -1095,9 +1268,15 @@
if ( ! window . confirm ( ` 确认删除“ ${ command ? . name || ` IR ${ slot } ` } ”的本地数据库记录吗?GSCU1B 设备中的该红外码不会被擦除。 ` ) ) return ;
remove . disabled = true ;
void api ( ` gscu/slots/ ${ slot } ` , { method : "DELETE" } ) . then ( ( result ) => {
const affectedDeviceId = command ? . deviceId ;
state . infraredCommands = namedInfraredCommands ( ) . filter ( ( item ) => item . slot !== slot ) ;
reconcileDevicesAndCapabilitiesFromSlots ( ) ;
saveState ( ) ;
renderGscuNamedCommands ( ) ;
renderAll ( ) ;
void syncPeripheralCatalog (
state . devices . find ( ( item ) => item . id === affectedDeviceId ) || null ,
) ;
showToast ( result . message || ` IR ${ slot } 的本地记录已删除。 ` , "success" ) ;
} ) . catch ( ( error ) => {
remove . disabled = false ;
@@ -1660,7 +1839,8 @@
const capability = state . capabilities . find (
( item ) => item . id === learning . draft . capabilityId ,
) ;
if ( ! capability ) return ;
const device = state . devices . find ( ( item ) => item . id === capability ? . deviceId ) ;
if ( ! capability || ! device ) return ;
const button = $ ( "#extensionConfirmLearn" ) ;
button . disabled = true ;
const relearning = Boolean ( learning . draft . relearning ) ;
@@ -1668,7 +1848,9 @@
try {
await upsertNamedInfraredCommand (
Number ( learning . draft . slot ) ,
capability . name ,
device . id ,
[ device . name , ... ( device . aliases || [ ] ) ] ,
[ capability . name , ... learning . draft . aliases ] ,
"capability" ,
) ;
} catch ( error ) {
@@ -1715,28 +1897,123 @@
} ) ;
saveState ( ) ;
renderAll ( ) ;
void syncPeripheralCatalog ( ) ;
showToast ( ` “ ${ capability . name } ”已恢复为待学习。 ` ) ;
}
function peripheralCatalog ( ) {
return learnedCapabilities ( ) . map ( ( capability ) => {
return configuredCapabilities ( ) . map ( ( capability ) => {
const device = state . devices . find ( ( item ) => item . id === capability . deviceId ) ;
const type = TOY _TYPES [ device ? . type ] || TOY _TYPES . other ;
const slotRecord = namedInfraredCommands ( ) . find (
( item ) => item . deviceId === capability . deviceId
&& Number ( item . slot ) === Number ( capability . slot ) ,
) ;
const objectNames = slotRecord ? . objectNames ? . length
? slotRecord . objectNames
: normalizedInfraredNames ( [ device ? . name , ... ( device ? . aliases || [ ] ) ] ) ;
const actionNames = slotRecord ? . actionNames ? . length
? slotRecord . actionNames
: normalizedInfraredNames ( [ capability . name , ... ( capability . aliases || [ ] ) ] ) ;
return {
id : capability . id ,
status : "learned" ,
device _id : device ? . id ,
device _name : device ? . name ,
device _aliases : device ? . aliases || [ ] ,
object _names : objectNames ,
device _type : device ? . type ,
card _uid : device ? . cardUid || null ,
toy _number : device ? . toyNumber || null ,
type _label : type . label ,
action : capability . name ,
aliases : capability . aliases || [ ] ,
action _names : actionNames ,
risk : capability . risk || "low" ,
slot : capability . slot ,
enabled : capability . enabled !== false ,
transport : "gscu_tcp" ,
} ;
} ) . filter ( ( item ) => item . device _id ) ;
} ) . filter ( ( item ) => item . device _id && item . card _uid && validSlot ( item . slot ) ) ;
}
async function hydratePeripheralCatalog ( ) {
const payload = await api ( "peripherals" ) ;
const catalog = Array . isArray ( payload . catalog ) ? payload . catalog : [ ] ;
if ( ! catalog . length ) return 0 ;
const restoredDeviceIds = new Set ( ) ;
catalog . forEach ( ( item ) => {
if ( ! item || typeof item !== "object" || ! item . id || ! validSlot ( item . slot ) ) return ;
const objectNames = normalizedInfraredNames ( item . object _names ) ;
const actionNames = normalizedInfraredNames ( item . action _names ) ;
let device = state . devices . find ( ( candidate ) => candidate . id === item . device _id ) ;
if ( ! device ) {
device = {
id : item . device _id ,
name : objectNames [ 0 ] || item . device _name ,
type : TOY _TYPES [ item . device _type ] ? item . device _type : "other" ,
aliases : objectNames . length
? objectNames
: Array . isArray ( item . device _aliases )
? item . device _aliases
: [ item . device _name ] ,
toyNumber : item . toy _number || nextToyNumber ( ) ,
cardUid : normalizeCardUid ( item . card _uid ) ,
createdAt : new Date ( ) . toISOString ( ) ,
} ;
state . devices . push ( device ) ;
restoredDeviceIds . add ( device . id ) ;
} else {
device . name = objectNames [ 0 ] || item . device _name || device . name ;
device . aliases = objectNames . length
? objectNames
: Array . isArray ( item . device _aliases )
? item . device _aliases
: device . aliases || [ device . name ] ;
device . cardUid = normalizeCardUid ( item . card _uid ) || device . cardUid ;
}
let capability = state . capabilities . find ( ( candidate ) => candidate . id === item . id ) ;
if ( ! capability ) {
capability = {
id : item . id ,
deviceId : device . id ,
key : "restored" ,
name : actionNames [ 0 ] || item . action ,
aliases : actionNames . length
? actionNames
: Array . isArray ( item . aliases ) ? item . aliases : [ item . action ] ,
category : "restored" ,
risk : item . risk || "low" ,
visual : null ,
createdAt : new Date ( ) . toISOString ( ) ,
} ;
state . capabilities . push ( capability ) ;
}
Object . assign ( capability , {
deviceId : device . id ,
name : actionNames [ 0 ] || item . action ,
aliases : actionNames . length
? actionNames
: Array . isArray ( item . aliases ) ? item . aliases : capability . aliases || [ ] ,
risk : item . risk || "low" ,
slot : Number ( item . slot ) ,
status : "learned" ,
verified : true ,
enabled : item . enabled !== false ,
signalSource : "backend_catalog" ,
} ) ;
} ) ;
restoredDeviceIds . forEach ( ( deviceId ) => {
const device = state . devices . find ( ( item ) => item . id === deviceId ) ;
if ( ! device ) return ;
const knownNames = new Set ( deviceCapabilities ( deviceId ) . map ( ( item ) => item . name ) ) ;
seedPresets ( device ) . forEach ( ( capability ) => {
if ( ! knownNames . has ( capability . name ) ) state . capabilities . push ( capability ) ;
} ) ;
} ) ;
saveState ( ) ;
renderAll ( ) ;
return catalog . length ;
}
function vehicleCatalog ( ) {
@@ -3046,6 +3323,7 @@
const reader = nfcSnapshot . reader || { } ;
const card = nfcSnapshot . card ;
const linkedDevice = linkedDeviceForCard ( card ? . uid ) ;
const deviceGroups = infraredDeviceGroups ( ) . filter ( ( group ) => group . deviceId ) ;
status . textContent = nfcReaderNote ( reader ) ;
badge . textContent = reader . online ? "READER READY" : "READER OFFLINE" ;
badge . dataset . state = reader . online ? "online" : "offline" ;
@@ -3055,37 +3333,25 @@
body . innerHTML = ` <div class="v2-nfc-waiting offline"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><small>NFC READER</small><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><small>WAITING FOR TOY </small><strong>请将玩具 NFC 靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后会自动显示该玩具及其动作能力 。</p></div></div> ` ;
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><small>WAITING FOR NFC </small><strong>请将 NFC 卡 靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到一个已配置的设备 ID 。</p></div></div> ` ;
} else if ( ! linkedDevice ) {
const suggestedNumber = nextToyNumber ( ) ;
stage . dataset . state = "new-card" ;
body . innerHTML = ` <div class="v2-nfc-detected-heading"><span><i data-lucide="badge-plus"></i></span><div><small>检测到新玩具卡片 </small><strong> ${ escapeHtml ( card . uid ) } </strong><p>这张 NFC 尚未绑定,可新建玩具档案或关联到已有玩具 。</p></div></div>
< div class = "v2-nfc-create-grid" >
< label > < span > 玩具名称 < /span><input id="extensionNfcToyName" maxlength="48" value="玩具 ${suggestedNumber}" / > < / l a b e l >
< label > < span > 玩具类别 < / s p a n > < s e l e c t i d = " e x t e n s i o n N f c T o y T y p e " > $ { O b j e c t . e n t r i e s ( T O Y _ T Y P E S ) . m a p ( ( [ k e y , t y p e ] ) = > ` < o p t i o n v a l u e = " $ { e s c a p e H t m l ( k e y ) } " > $ { e s c a p e H t m l ( t y p e . l a b e l ) } < / o p t i o n > ` ) . j o i n ( " " ) } < / s e l e c t > < / l a b e l >
< / d i v >
< div class = "v2-nfc-primary-actions" > < button class = "v2-extension-primary" type = "button" data - nfc - create - and - learn > < i data - lucide = "radio-tower" > < / i > 建 立 档 案 并 开 始 学 习 < / b u t t o n > < / d i v >
$ { state . devices . length ? ` <div class="v2-nfc-existing"><span>或绑定到已有玩具</span><div><select id="extensionNfcExistingDevice"> ${ state . devices . map ( ( device ) => ` <option value=" ${ escapeHtml ( device . id ) } "> ${ device . toyNumber ? ` # ${ escapeHtml ( device . toyNumber ) } · ` : "" } ${ escapeHtml ( device . name ) } </option> ` ) . join ( "" ) } </select><button type="button" data-nfc-bind-existing>绑定</button></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>
$ { 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 {
const type = TOY _TYPES [ linkedDevice . type ] || TOY _TYPES . other ;
const capabilities = deviceCapabilities ( linkedDevice . id ) ;
const learned = learnedCapabilities ( linkedDevice . id ) ;
const firstPending = capabilities . find ( ( capability ) => capability . status ! == "learned" ) ;
const group = deviceGroups . find ( ( item ) => item . deviceId === linkedDevice . id ) ;
const capabilities = configuredCapabilities ( linkedDevice . id ) ;
const enabledCount = capabilities . filter ( ( capability ) => capability . enabled !== false ) . length ;
const allEnabled = capabilities . length > 0 && enabledCount === capabilities . length ;
stage . dataset . state = "recognized" ;
body . innerHTML = ` <div class="v2-nfc-recognized-heading">
< span data - extension - type = "${escapeHtml(linkedDevice.type || " other ")} "> < i data - lucide = "${type.icon} " > < / i > < / s p a n >
< div > < small > 已识别并绑定 · $ { escapeHtml ( card . uid ) } < / s m a l l > < s t r o n g > $ { l i n k e d D e v i c e . t o y N u m b e r ? ` # $ { e s c a p e H t m l ( l i n k e d D e v i c e . t o y N u m b e r ) } · ` : " " } $ { e s c a p e H t m l ( l i n k e d D e v i c e . n a m e ) } < / s t r o n g > < p > $ { e s c a p e H t m l ( t y p e . l a b e l ) } · $ { l e a r n e d . l e n g t h } 项 动 作 可 执 行 < / p > < / d i v >
< span data - extension - type = "other " > < i data - lucide = "cpu " > < / i > < / s p a n >
< div > < small > NFC UID · $ { escapeHtml ( card . uid ) } < / s m a l l > < s t r o n g > $ { e s c a p e H t m l ( l i n k e d D e v i c e . i d ) } < / s t r o n g > < p > 控 制 对 象 : $ { e s c a p e H t m l ( g r o u p ? . o b j e c t N a m e s . j o i n ( " 、 " ) | | l i n k e d D e v i c e . n a m e | | " 无 辅 助 描 述 " ) } · $ { e n a b l e d C o u n t } / $ { c a p a b i l i t i e s . l e n g t h } 条 命 令 已 授 权 < / p > < / d i v >
< i data - lucide = "badge-check" > < / i >
< / d i v >
< div class = "v2-nfc-capability-heading" > < div > < small > TOY ACTION CAPABILITIES < / s m a l l > < s t r o n g > 玩 具 动 作 能 力 < / s t r o n g > < / d i v > < s p a n > $ { l e a r n e d . l e n g t h } / $ { c a p a b i l i t i e s . l e n g t h } 已 学 习 < / s p a n > < / d i v >
< div class = "v2-nfc-capability-grid" > $ { capabilities . map ( ( capability ) => {
const isLearned = learned . some ( ( item ) => item . id === capability . id ) ;
if ( ! isLearned ) {
return ` <div class="v2-nfc-capability-card pending"><button class="v2-nfc-capability-primary" type="button" data-nfc-learn-capability=" ${ escapeHtml ( capability . id ) } "><span> ${ escapeHtml ( capability . name ) } </span><small>待学习 · 点击录入</small><i data-lucide="plus"></i></button></div> ` ;
}
return ` <div class="v2-nfc-capability-card learned"><button class="v2-nfc-capability-primary" type="button" data-nfc-run-capability=" ${ escapeHtml ( capability . id ) } "><span> ${ escapeHtml ( capability . name ) } </span><small>IR ${ String ( capability . slot ) . padStart ( 3 , "0" ) } · 点击发射</small><i data-lucide="send"></i></button><button class="v2-nfc-relearn" type="button" data-nfc-relearn-capability=" ${ escapeHtml ( capability . id ) } " title="覆盖原槽位并重新学习“ ${ escapeHtml ( capability . name ) } ”"><i data-lucide="refresh-cw"></i><span>重新学习</span></button></div> ` ;
} ) . join ( "" ) } < / d i v >
< div class = "v2-nfc-recognized-actions" > < button class = "v2-extension-primary" type = "button" data - nfc - continue - learning $ { firstPending ? "" : "disabled" } > < i data - lucide = "radio-tower" > < / i > $ { f i r s t P e n d i n g ? " 继 续 学 习 红 外 " : " 动 作 已 全 部 学 习 " } < / b u t t o n > < b u t t o n t y p e = " b u t t o n " d a t a - n f c - u n b i n d > 解 绑 N F C < / b u t t o n > < / d i v > ` ;
< div class = "v2-nfc-capability-heading" > < div > < small > COMMAND AUTHORIZATION < / s m a l l > < s t r o n g > 允 许 此 N F C 执 行 的 命 令 < / s t r o n g > < / d i v > < s p a n > $ { e n a b l e d C o u n t } / $ { c a p a b i l i t i e s . l e n g t h } 已 选 择 < / s p a n > < / d i v >
$ { 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 > 复选框状态会持久化为自然语言执行白名单 。 < / s p a n > < b u t t o n t y p e = " b u t t o n " d a t a - n f c - u n b i n d > 解 绑 N F C < / b u t t o n > < / d i v > ` ;
}
window . lucide ? . createIcons ( { attrs : { "aria-hidden" : "true" } } ) ;
}
@@ -3095,12 +3361,12 @@
const dialog = document . createElement ( "dialog" ) ;
dialog . className = "v2-extension-dialog v2-nfc-dialog" ;
dialog . id = "extensionNfcDialog" ;
dialog . innerHTML = ` <header><div><span class="section-kicker">NFC · INFRARED LEARNING</span><h2>感应并学习玩具</h2><p>刷卡识别玩具,再通过现有 GSCU1B 链路学习和验证红外动作 。</p></div><button type="button" data-close-nfc-dialog aria-label="关闭"><i data-lucide="x"></i></button></header>
dialog . innerHTML = ` <header><div><span class="section-kicker">NFC · DEVICE AUTHORIZATION</span><h2>NFC 设备授权</h2><p>将 NFC UID 绑定到设备 ID,并限制该卡片可执行的红外命令 。</p></div><button type="button" data-close-nfc-dialog aria-label="关闭"><i data-lucide="x"></i></button></header>
< section class = "v2-nfc-stage" id = "extensionNfcStage" data - state = "waiting" >
< header > < span > < i data - lucide = "scan-line" > < / i > < / s p a n > < d i v > < s m a l l > 靠 近 读 卡 器 后 < / s m a l l > < h 3 > 玩 具 N F C 感 应 区 < / h 3 > < p i d = " e x t e n s i o n N f c R e a d e r S t a t u s " > 正 在 检 查 读 卡 器 < / p > < / d i v > < b i d = " e x t e n s i o n N f c R e a d e r B a d g e " d a t a - s t a t e = " o f f l i n e " > R E A D E R C H E C K < / b > < / h e a d e r >
< header > < span > < i data - lucide = "scan-line" > < / i > < / s p a n > < d i v > < s m a l l > 靠 近 读 卡 器 后 < / s m a l l > < h 3 > N F C 设 备 绑 定 < / h 3 > < p i d = " e x t e n s i o n N f c R e a d e r S t a t u s " > 正 在 检 查 读 卡 器 < / p > < / d i v > < b i d = " e x t e n s i o n N f c R e a d e r B a d g e " d a t a - s t a t e = " o f f l i n e " > R E A D E R C H E C K < / b > < / h e a d e r >
< div class = "v2-nfc-body" id = "extensionNfcBody" > < / d i v >
< / s e c t i o n >
< footer class = "v2-nfc-footnote" > < i data - lucide = "shield-check" > < / i > < s p a n > N F C U I D 与 玩 具 动 作 绑 定 保 存 在 当 前 浏 览 器 ; 红 外 波 形 保 存 在 G S C U 1 B 物 理 槽 位 。 < / s p a n > < / f o o t e r > ` ;
< footer class = "v2-nfc-footnote" > < i data - lucide = "shield-check" > < / i > < s p a n > N F C U I D 、 设 备 I D 与 命 令 授 权 同 步 到 本 机 白 名 单 ; 红 外 波 形 仍 保 存 在 G S C U 1 B 物 理 槽 位 。 < / s p a n > < / f o o t e r > ` ;
document . body . append ( dialog ) ;
}
@@ -3131,43 +3397,18 @@
void syncPeripheralCatalog ( device ) ;
}
function createToyFromNfc ( ) {
const cardUid = nfcSnapshot . card ? . uid ;
const name = $ ( "#extensionNfcToyName" ) ? . value . trim ( ) ;
const typeKey = $ ( "#extensionNfcToyType" ) ? . value ;
if ( ! cardUid ) return showToast ( "请先将玩具 NFC 靠近读卡器。" , "error" ) ;
if ( ! name ) return showToast ( "请输入玩具名称。" , "error" ) ;
if ( linkedDeviceForCard ( cardUid ) ) return showToast ( "这张 NFC 已绑定玩具。" , "error" ) ;
if ( state . devices . some ( ( device ) => device . name === name ) ) {
return showToast ( "已经存在同名玩具,请改用其他名称。" , "error" ) ;
}
const device = {
id : uid ( "toy" ) ,
name ,
type : TOY _TYPES [ typeKey ] ? typeKey : "other" ,
aliases : [ name ] ,
toyNumber : nextToyNumber ( ) ,
cardUid : normalizeCardUid ( cardUid ) ,
createdAt : new Date ( ) . toISOString ( ) ,
} ;
const capabilities = seedPresets ( device ) ;
state . devices . push ( device ) ;
state . capabilities . push ( ... capabilities ) ;
state . selectedDeviceByType [ device . type ] = device . id ;
saveState ( ) ;
renderAll ( ) ;
void syncPeripheralCatalog ( device ) ;
showToast ( ` 已识别“ ${ device . name } ”,请选择遥控动作开始学习。 ` , "success" ) ;
if ( capabilities [ 0 ] ) openLearn ( capabilities [ 0 ] . id ) ;
}
function bindCurrentNfcToExisting ( ) {
const deviceId = $ ( "#extensionNfcExistingDevice" ) ? . value ;
reconcileDevicesAndCapabilitiesFromSlots ( ) ;
const device = state . devices . find ( ( item ) => item . id === deviceId ) ;
const cardUid = nfcSnapshot . card ? . uid ;
if ( ! device || ! cardUid ) return ;
configuredCapabilities ( device . id ) . forEach ( ( capability ) => {
capability . enabled = true ;
} ) ;
bindCardToDevice ( device , cardUid ) ;
showToast ( ` NFC 已绑定到“ ${ device . name } ”。 ` , "success" ) ;
renderNfcDialog ( ) ;
showToast ( ` NFC 已关联设备 ID“ ${ device . id } ”,全部命令已默认选中。 ` , "success" ) ;
}
function unbindCurrentNfc ( ) {
@@ -3177,36 +3418,33 @@
saveState ( ) ;
renderAll ( ) ;
void syncPeripheralCatalog ( device ) ;
showToast ( ` 已解除“ ${ device . name } ”的 NFC 绑定。 ` ) ;
renderNfcDialog ( ) ;
showToast ( ` 已解除设备 ID“ ${ device . id } ”的 NFC 绑定。 ` ) ;
}
function continueNfcLearning ( capabilityId = null ) {
function updateNfcCapabilityAuthorization ( capabilityId , enabled ) {
const device = linkedDeviceForCard ( nfcSnapshot . card ? . uid ) ;
const capability = capabilityId
? state . capabilities . find ( ( item ) => item . id === capabilityId )
: deviceCapabilities ( device ? . id ) . find ( ( item ) => item . status !== "learned" ) ;
if ( ! device || ! capability ) {
showToast ( "该玩具没有待学习动作。" , "success" ) ;
return ;
}
state . selectedDeviceByType [ device . type ] = device . id ;
const capability = configuredCapabilities ( device ? . id ) . find (
( item ) => item . id === capabilityId ,
) ;
if ( ! device || ! capability ) return ;
capability . enabled = enabled ;
saveState ( ) ;
openLearn ( capability . id ) ;
renderAll ( ) ;
renderNfcDialog ( ) ;
void syncPeripheralCatalog ( device ) ;
}
async function emitNfcCapability ( capabilityId ) {
const capability = learnedCapabilities ( ) . find ( ( item ) => item . id === capabilityId ) ;
const device = state . devices . find ( ( item ) => item . id === capability ? . deviceId ) ;
if ( ! capability || ! device ) return ;
if ( capability . risk === "high" && ! window . confirm (
` “ ${ capability . name } ”可能让玩具移动,请确认周围安全。 ` ,
) ) return ;
try {
await sendGatewayCommand ( "emit" , { slot : capability . slot } ) ;
showToast ( ` 已向“ ${ device . name } ”发射“ ${ capability . name } ”。 ` , "success" ) ;
} catch ( error ) {
showToast ( error . message , "error" ) ;
}
function updateAllNfcCapabilityAuthorizations ( enabled ) {
const device = linkedDeviceForCard ( nfcSnapshot . card ? . uid ) ;
if ( ! device ) return ;
configuredCapabilities ( device . id ) . forEach ( ( capability ) => {
capability . enabled = enabled ;
} ) ;
saveState ( ) ;
renderAll ( ) ;
renderNfcDialog ( ) ;
void syncPeripheralCatalog ( device ) ;
}
function applyNfcSnapshot ( value , { announce = true } = { } ) {
@@ -3232,10 +3470,10 @@
if ( device ) {
state . selectedDeviceByType [ device . type ] = device . id ;
saveState ( ) ;
if ( announce ) showToast ( ` 已识别玩具 “ ${ device . name } ”。 ` , "success" ) ;
if ( announce ) showToast ( ` 已识别设备 ID “ ${ device . id } ”。 ` , "success" ) ;
void syncPeripheralCatalog ( device ) ;
} else if ( announce ) {
showToast ( "检测到新的玩具 NFC,请建立或绑定玩具档案 。" , "success" ) ;
showToast ( "检测到未绑定 NFC,请选择一个设备 ID 。" , "success" ) ;
}
}
renderNfcDialog ( ) ;
@@ -3275,23 +3513,27 @@
function bindUnifiedNfcEvents ( mount ) {
mount . addEventListener ( "click" , ( event ) => {
if ( event . target . closest ( "[data-open-nfc-learn ing]" ) ) openNfcDialog ( ) ;
if ( event . target . closest ( "[data-open-nfc-bind ing]" ) ) openNfcDialog ( ) ;
if ( event . target . closest ( "[data-open-gscu-tcp]" ) ) openGscuTcpDialog ( ) ;
} ) ;
bindGscuTcpDialogEvents ( ) ;
const dialog = $ ( "#extensionNfcDialog" ) ;
dialog . addEventListener ( "click" , ( event ) => {
if ( event . target . closest ( "[data-close-nfc-dialog]" ) ) closeDialog ( "extensionNfcDialog" ) ;
if ( event . target . closest ( "[data-nfc-create-and-learn]" ) ) createToyFromNfc ( ) ;
if ( event . target . closest ( "[data-nfc-bind-existing]" ) ) bindCurrentNfcToExisting ( ) ;
if ( event . target . closest ( "[data-nfc-unbind]" ) ) unbindCurrentNfc ( ) ;
const relearn = event . target . closest ( "[data-nfc-relearn-capability]" ) ;
if ( relearn ) continueNfcLearning ( relearn . dataset . nfcRelearnCapability ) ;
const learn = event . target . closest ( "[data-nfc-learn- capability]" ) ;
if ( learn ) continueNfcLearning ( learn . dataset . nfcLearnCapability ) ;
if ( event . target . closest ( "[data-nfc-continue-learning]" ) ) continueNfcLearning ( ) ;
const run = event . target . closest ( "[data-nfc-run-capability]" ) ;
if ( run ) void emitNfcCapability ( run . dataset . nfcRunCapability ) ;
} ) ;
dialog . addEventListener ( "change" , ( event ) => {
const capability = event . target . closest ( "[data-nfc-capability-enabled ]" ) ;
if ( capability ) {
updateNfcCapabilityAuthorization (
capability . dataset . nfcCapabilityEnabled ,
capability . checked ,
) ;
return ;
}
const selectAll = event . target . closest ( "[data-nfc-select-all]" ) ;
if ( selectAll ) updateAllNfcCapabilityAuthorizations ( selectAll . checked ) ;
} ) ;
$ ( "#extensionContinueLearn" ) . addEventListener ( "click" , ( ) => {
try {
@@ -3370,6 +3612,7 @@
? mergedCatalog . filter ( ( item ) => (
normalizeCardUid ( item . card _uid ) === sensedCardUid
&& item . status === "learned"
&& item . enabled !== false
) )
: [ ] ;
const grouped = new Map ( ) ;
@@ -3387,12 +3630,14 @@
const devices = [ ... grouped . values ( ) ] ;
const lastAction = snapshot ? . extension ? . last _action ;
const lastResult = snapshot ? . extension ? . result ;
const nfcCapabilities = nfcDevice ? learnedCapabilities ( nfcDevice . id ) : [ ] ;
const nfcCapabilities = nfcDevice
? configuredCapabilities ( nfcDevice . id ) . filter ( ( item ) => item . enabled !== false )
: [ ] ;
const panelCollapsed = mount . closest ( "#v2ExtensionPanel" ) ? . classList . contains ( "is-collapsed" ) === true ;
const statusLabel = devices . length
? "已感应 NFC 外设"
: sensedCardUid
? "当前 NFC 暂无已学习能力 "
? "当前 NFC 暂无已授权命令 "
: nfcReader . online
? "等待 NFC 外设靠近"
: "NFC 读卡器离线" ;
@@ -3405,7 +3650,7 @@
: "红外链路检查中" ;
const gatewayNote = physicalConnected
? ` ${ health . peer || "GSCU1B" } · 红外学习与发射链路已就绪 `
: health . message || "红外学习器接入后可学习并发射玩具 动作" ;
: health . message || "红外学习器接入后可学习并发射设备 动作" ;
const visibleLastAction = devices . length ? lastAction : null ;
const nfcState = ! nfcReader . online
? "offline"
@@ -3415,28 +3660,28 @@
? "new-card"
: "waiting" ;
const nfcTitle = nfcDevice
? ` ${ nfcDevice . toyNumber ? ` # ${ nfcDevice . toyNumber } · ` : "" } ${ nfcDevice . name } `
? nfcDevice . id
: nfcCard ? . uid
? "检测到新的玩具 NFC"
? "检测到未绑定 NFC"
: nfcReader . online
? "等待玩具 靠近"
? "等待 NFC 卡 靠近"
: "NFC 读卡器未连接" ;
const nfcNote = nfcDevice
? ` ${ nfcCapabilities . length } 项已学习动作 · ${ nfcCard . uid } `
? ` ${ nfcCapabilities . length } 条命令已授权 · ${ nfcCard . uid } `
: nfcCard ? . uid
? ` ${ nfcCard . uid } · 点击建立或绑定玩具档案 `
? ` ${ nfcCard . uid } · 点击关联设备 ID `
: nfcReaderNote ( nfcReader ) ;
mount . dataset . hasDevices = String ( devices . length > 0 ) ;
mount . innerHTML = `
< header class = "v2-extension-sync-heading" >
< div > < small > USB EXTENSION · LIVE < / s m a l l > < h 3 > 外 设 与 扩 展 能 力 < / h 3 > < b u t t o n c l a s s = " v 2 - e x t e n s i o n - n f c - l a u n c h " t y p e = " b u t t o n " d a t a - o p e n - n f c - l e a r n i n g > < i d a t a - l u c i d e = " s c a n - l i n e " > < / i > < s p a n > 感 应 玩 具 N F C < / s p a n > < / b u t t o n > < / d i v >
< div > < small > USB EXTENSION · LIVE < / s m a l l > < h 3 > 外 设 与 扩 展 能 力 < / h 3 > < b u t t o n c l a s s = " v 2 - e x t e n s i o n - n f c - l a u n c h " t y p e = " b u t t o n " d a t a - o p e n - n f c - b i n d i n g > < i d a t a - l u c i d e = " s c a n - l i n e " > < / i > < s p a n > N F C 设 备 绑 定 < / s p a n > < / b u t t o n > < / d i v >
< span data - state = "${devices.length ? " online " : nfcReader.online ? " listening " : " offline "}" > < i > < / i > $ { s t a t u s L a b e l } < / s p a n >
< button type = "button" data - panel - toggle = "extensions" aria - label = "${panelCollapsed ? " 展开 " : " 收起 "}外设与扩展能力" > < i data - lucide = "panel-right-close" > < / i > < s p a n d a t a - p a n e l - a c t i o n - l a b e l > $ { p a n e l C o l l a p s e d ? " 展 开 " : " 收 起 " } < / s p a n > < / b u t t o n >
< / h e a d e r >
< 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 > < / s p a n >
< div > < small > 靠近读卡器后 < / s m a l l > < s t r o n g > $ { e s c a p e H t m l ( n f c T i t l e ) } < / s t r o n g > < p > $ { e s c a p e H t m l ( n f c N o t e ) } < / p > $ { n f c C a p a b i l i t i e s . l e n g t h ? ` < d i v > $ { n f c C a p a b i l i t i e s . s l i c e ( 0 , 6 ) . m a p ( ( c a p a b i l i t y ) = > ` < i > $ { e s c a p e H t m l ( c a p a b i l i t y . n a m e ) } < / i > ` ) . j o i n ( " " ) } < / d i v > ` : " " } < / d i v >
< button type = "button" data - open - nfc - learning > $ { nfcDevice ? "查看能力 " : nfcCard ? . uid ? "建立档案 " : "开始感应" } < / b u t t o n >
< button type = "button" data - open - nfc - binding > $ { nfcDevice ? "查看授权 " : nfcCard ? . uid ? "关联设备 " : "开始感应" } < / b u t t o n >
< / s e c t i o n >
< div class = "v2-extension-gateway" >
< span > < i data - lucide = "usb" > < / i > < / s p a n >
@@ -3451,7 +3696,7 @@
< div > $ { device . actions . slice ( 0 , 8 ) . map ( ( action ) => ` <span> ${ escapeHtml ( action ) } </span> ` ) . join ( "" ) } < / d i v >
< / a r t i c l e > ` ) . j o i n ( " " ) : `
< div class = "v2-extension-empty" >
< i data - lucide = "${sensedCardUid ? " badge - alert " : " scan - line "}" > < / i > < d i v > < s t r o n g > $ { s e n s e d C a r d U i d ? " 当 前 N F C 暂 无 可 显 示 能 力 " : " 当 前 未 感 应 到 外 设 " } < / s t r o n g > < s p a n > $ { s e n s e d C a r d U i d ? " 仅 显 示 已 绑 定 并 完 成 红 外 学 习 的 动 作 " : " 将 已 绑 定 且 已 学 习 的 N F C 外 设 靠 近 读 卡 器 后 显 示 能 力 " } < / s p a n > < / d i v >
< i data - lucide = "${sensedCardUid ? " badge - alert " : " scan - line "}" > < / i > < d i v > < s t r o n g > $ { s e n s e d C a r d U i d ? " 当 前 N F C 暂 无 已 授 权 命 令 " : " 当 前 未 感 应 到 外 设 " } < / s t r o n g > < s p a n > $ { s e n s e d C a r d U i d ? " 请 检 查 设 备 I D 绑 定 及 命 令 复 选 框 " : " 将 已 绑 定 设 备 I D 的 N F C 卡 靠 近 读 卡 器 后 显 示 命 令 " } < / s p a n > < / d i v >
< / d i v > ` }
< / d i v >
< footer class = "v2-extension-linkage" data - active = "${String(Boolean(visibleLastAction))}" >
@@ -3478,7 +3723,11 @@
render ( ) ;
renderNfcDialog ( ) ;
startNfcPolling ( ) ;
void syncPeripheralCatalog ( ) ;
void hydratePeripheralCatalog ( )
. catch ( ( error ) => {
console . warn ( "恢复后端红外能力目录失败,将保留浏览器本地目录。" , error ) ;
} )
. finally ( ( ) => syncPeripheralCatalog ( ) ) ;
return true ;
}