@@ -75,7 +75,9 @@
. eyebrow { color : var ( - - accent - strong ) ; font-size : 12 px ; font-weight : 700 ; letter-spacing : .08 em ; text-transform : uppercase ; }
. eyebrow { color : var ( - - accent - strong ) ; font-size : 12 px ; font-weight : 700 ; letter-spacing : .08 em ; text-transform : uppercase ; }
h1 { margin : 5 px 0 6 px ; font-size : clamp ( 24 px , 3 vw , 32 px ) ; letter-spacing : -.035 em ; }
h1 { margin : 5 px 0 6 px ; font-size : clamp ( 24 px , 3 vw , 32 px ) ; letter-spacing : -.035 em ; }
. subtitle { margin : 0 ; color : var ( - - muted ) ; }
. subtitle { margin : 0 ; color : var ( - - muted ) ; }
. top-actions { display : flex ; gap : 8 px ; align-items : center ; }
. top-actions { display : flex ; gap : 8 px ; align-items : center ; width : min ( 468 px , 50 vw ) ; }
. instance-commands { display : grid ; grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 8 px ; flex : 1 ; }
. instance-commands button { width : 100 % ; }
. icon-button , . secondary , . primary , . danger-button { min-height : 36 px ; padding : 0 14 px ; border-radius : var ( - - radius - control ) ; font-weight : 650 ; white-space : nowrap ; }
. icon-button , . secondary , . primary , . danger-button { min-height : 36 px ; padding : 0 14 px ; border-radius : var ( - - radius - control ) ; font-weight : 650 ; white-space : nowrap ; }
. icon-button , . secondary { color : var ( - - text ) ; background : var ( - - surface ) ; border : 1 px solid var ( - - line - strong ) ; }
. icon-button , . secondary { color : var ( - - text ) ; background : var ( - - surface ) ; border : 1 px solid var ( - - line - strong ) ; }
. icon-button { width : 38 px ; padding : 0 ; }
. icon-button { width : 38 px ; padding : 0 ; }
@@ -153,12 +155,17 @@
. activity-item { display : grid ; grid-template-columns : 9 px 1 fr ; gap : 9 px ; font-size : 12 px ; color : var ( - - muted ) ; }
. activity-item { display : grid ; grid-template-columns : 9 px 1 fr ; gap : 9 px ; font-size : 12 px ; color : var ( - - muted ) ; }
. activity-item i { width : 8 px ; height : 8 px ; background : var ( - - accent ) ; border-radius : 50 % ; margin-top : 4 px ; }
. activity-item i { width : 8 px ; height : 8 px ; background : var ( - - accent ) ; border-radius : 50 % ; margin-top : 4 px ; }
dialog { border : 0 ; border-radius : var ( - - radius - dialog ) ; padding : 0 ; width : min ( 430 px , calc ( 100 vw - 32 px ) ) ; color : var ( - - text ) ; background : var ( - - surface ) ; box-shadow : var ( - - shadow ) ; }
dialog { border : 0 ; border-radius : var ( - - radius - dialog ) ; padding : 0 ; width : min ( 430 px , calc ( 100 vw - 32 px ) ) ; color : var ( - - text ) ; background : var ( - - surface ) ; box-shadow : var ( - - shadow ) ; }
dialog . edit-dialog { width : min ( 620 px , calc ( 100 vw - 32 px ) ) ; }
dialog :: backdrop { background : rgba ( 16 , 24 , 40 , .42 ) ; }
dialog :: backdrop { background : rgba ( 16 , 24 , 40 , .42 ) ; }
. dialog-inner { padding : 24 px ; display : grid ; gap : 17 px ; }
. dialog-inner { padding : 24 px ; display : grid ; gap : 17 px ; }
. dialog-inner h2 { margin : 0 ; font-size : 19 px ; }
. dialog-inner h2 { margin : 0 ; font-size : 19 px ; }
. dialog-inner p { margin : 0 ; color : var ( - - muted ) ; line-height : 1.55 ; }
. dialog-inner p { margin : 0 ; color : var ( - - muted ) ; line-height : 1.55 ; }
. dialog-warning { padding : 11 px 12 px ; border-radius : 9 px ; background : var ( - - danger - soft ) ; color : var ( - - danger ) ; font-size : 12 px ; line-height : 1.45 ; }
. dialog-warning { padding : 11 px 12 px ; border-radius : 9 px ; background : var ( - - danger - soft ) ; color : var ( - - danger ) ; font-size : 12 px ; line-height : 1.45 ; }
. dialog-actions { display : flex ; justify-content : flex-end ; gap : 8 px ; }
. dialog-actions { display : flex ; justify-content : flex-end ; gap : 8 px ; }
. edit-form { display : grid ; gap : 18 px ; }
. edit-form . path-field { grid-template-columns : minmax ( 0 , 1 fr ) auto ; }
. readonly-value { background : var ( - - surface -3 ) !important ; color : var ( - - muted ) !important ; cursor : default ; }
. form-note { padding : 10 px 12 px ; border-radius : 9 px ; background : var ( - - warning - soft ) ; color : var ( - - warning ) ; font-size : 12 px ; line-height : 1.5 ; }
. empty-state { display : none ; text-align : center ; padding : 48 px 20 px ; color : var ( - - muted ) ; }
. empty-state { display : none ; text-align : center ; padding : 48 px 20 px ; color : var ( - - muted ) ; }
. empty-state strong { display : block ; color : var ( - - text ) ; font-size : 16 px ; margin-bottom : 7 px ; }
. empty-state strong { display : block ; color : var ( - - text ) ; font-size : 16 px ; margin-bottom : 7 px ; }
. sr-only { position : absolute ; width : 1 px ; height : 1 px ; padding : 0 ; margin : -1 px ; overflow : hidden ; clip : rect ( 0 , 0 , 0 , 0 ) ; white-space : nowrap ; border : 0 ; }
. sr-only { position : absolute ; width : 1 px ; height : 1 px ; padding : 0 ; margin : -1 px ; overflow : hidden ; clip : rect ( 0 , 0 , 0 , 0 ) ; white-space : nowrap ; border : 0 ; }
@@ -197,8 +204,8 @@
. nav button span : not ( . nav-icon ) { display : none ; }
. nav button span : not ( . nav-icon ) { display : none ; }
. main { padding : 28 px 12 px ; }
. main { padding : 28 px 12 px ; }
. topbar { display : block ; }
. topbar { display : block ; }
. top-actions { margin-top : 16 px ; }
. top-actions { margin-top : 16 px ; width : 100 % ; }
. top-actions . primary { flex : 1 ; }
. instance-commands { grid-template-columns : 1 fr ; }
. summary-grid { gap : 8 px ; }
. summary-grid { gap : 8 px ; }
. summary { padding : 12 px ; }
. summary { padding : 12 px ; }
. summary-value { font-size : 21 px ; }
. summary-value { font-size : 21 px ; }
@@ -225,7 +232,7 @@
< main class = "main" >
< main class = "main" >
< header class = "topbar" >
< header class = "topbar" >
< div > < div class = "eyebrow" id = "pageEyebrow" > Browser instances< / div > < h1 id = "pageTitle" > 浏览器实例< / h1 > < p class = "subtitle" id = "pageSubtitle" > 启动、观察并安全管理 Chrome 与 Edge 环境< / p > < / div >
< div > < div class = "eyebrow" id = "pageEyebrow" > Browser instances< / div > < h1 id = "pageTitle" > 浏览器实例< / h1 > < p class = "subtitle" id = "pageSubtitle" > 启动、观察并安全管理 Chrome 与 Edge 环境< / p > < / div >
< div class = "top-actions" > < button class = "icon-button" id = "themeToggle" aria-label = "切换深色主题" title = "切换深色主题" > ☾< / button > < button class = "primary" id = "newInstance" > + 新建实例< / button > < / div >
< div class = "top-actions" > < button class = "icon-button" id = "themeToggle" aria-label = "切换深色主题" title = "切换深色主题" > ☾< / button > < div class = "instance-commands" id = "instanceCommands" > < button class = "primary" id = "newInstance" > + 新建实例< / button > < button class = "secondary" id = "refresh" title = "刷新实例状态 (F5)" > ↻ 刷新状态 < / button > < / div > < / div >
< / header >
< / header >
< section class = "content" id = "instancesPage" aria-label = "实例工作区" >
< section class = "content" id = "instancesPage" aria-label = "实例工作区" >
@@ -238,7 +245,7 @@
< / section >
< / section >
< section class = "workspace" aria-labelledby = "listTitle" >
< section class = "workspace" aria-labelledby = "listTitle" >
< div class = "toolbar" > < div class = "search" > < span aria-hidden = "true" > ⌕< / span > < input id = "search" type = "search" placeholder = "搜索实例、路径、PID 或端口" aria-label = "搜索实例、路径、PID 或端口" > < / div > < select id = "browserFilter" aria-label = "筛选浏览器" > < option value = "all" > 全部浏览器< / option > < option value = "chrome" > Chrome< / option > < option value = "edge" > Edge< / option > < / select > < select id = "statusFilter" aria-label = "筛选状态" > < option value = "all" > 全部状态< / option > < option value = "running" > 运行中< / option > < option value = "starting" > 启动中< / option > < option value = "associated" > 外部已关联< / option > < option value = "occupied" > 被占用< / option > < option value = "exited" > 已退出< / option > < / select > < div class = "toolbar-spacer" > < / div > < button class = "secondary" id = "refresh" > ↻ 刷新状态 < / button > < / div >
< div class = "toolbar" > < div class = "search" > < span aria-hidden = "true" > ⌕< / span > < input id = "search" type = "search" placeholder = "搜索实例、路径、PID 或端口" aria-label = "搜索实例、路径、PID 或端口" > < / div > < select id = "browserFilter" aria-label = "筛选浏览器" > < option value = "all" > 全部浏览器< / option > < option value = "chrome" > Chrome< / option > < option value = "edge" > Edge< / option > < / select > < select id = "statusFilter" aria-label = "筛选状态" > < option value = "all" > 全部状态< / option > < option value = "running" > 运行中< / option > < option value = "starting" > 启动中< / option > < option value = "associated" > 外部已关联< / option > < option value = "occupied" > 被占用< / option > < option value = "exited" > 已退出< / option > < / select > < / div >
< div class = "table-wrap" > < table > < caption id = "listTitle" class = "sr-only" > 浏览器实例列表< / caption > < thead > < tr > < th scope = "col" > 实例名称< / th > < th scope = "col" > 浏览器类型< / th > < th scope = "col" > 用户数据目录< / th > < th scope = "col" > 调试端口< / th > < th scope = "col" > 状态< / th > < th scope = "col" > 操作< / th > < / tr > < / thead > < tbody id = "instanceRows" > < / tbody > < / table > < div class = "empty-state" id = "emptyState" > < strong > 没有匹配的实例< / strong > < span > 尝试清除筛选,或新建一个浏览器实例。< / span > < / div > < / div >
< div class = "table-wrap" > < table > < caption id = "listTitle" class = "sr-only" > 浏览器实例列表< / caption > < thead > < tr > < th scope = "col" > 实例名称< / th > < th scope = "col" > 浏览器类型< / th > < th scope = "col" > 用户数据目录< / th > < th scope = "col" > 调试端口< / th > < th scope = "col" > 状态< / th > < th scope = "col" > 操作< / th > < / tr > < / thead > < tbody id = "instanceRows" > < / tbody > < / table > < div class = "empty-state" id = "emptyState" > < strong > 没有匹配的实例< / strong > < span > 尝试清除筛选,或新建一个浏览器实例。< / span > < / div > < / div >
< div class = "table-footer" > < span id = "resultCount" > 显示 4 个实例< / span > < span > 最后检查:刚刚< / span > < / div >
< div class = "table-footer" > < span id = "resultCount" > 显示 4 个实例< / span > < span > 最后检查:刚刚< / span > < / div >
< / section >
< / section >
@@ -266,6 +273,8 @@
< div class = "drawer-backdrop" id = "backdrop" > < / div >
< div class = "drawer-backdrop" id = "backdrop" > < / div >
< aside class = "drawer" id = "drawer" aria-label = "实例详情和配置" aria-hidden = "true" > < / aside >
< aside class = "drawer" id = "drawer" aria-label = "实例详情和配置" aria-hidden = "true" > < / aside >
< dialog id = "confirmDialog" aria-labelledby = "dialogTitle" > < div class = "dialog-inner" > < h2 id = "dialogTitle" > 关闭浏览器实例?< / h2 > < p id = "dialogText" > 将请求浏览器正常退出。未保存的网页内容由浏览器自行处理。< / p > < div class = "dialog-warning" id = "dialogWarning" hidden > 强制终止可能造成 profile 数据损坏。仅在浏览器无响应时使用。< / div > < div class = "dialog-actions" > < button class = "secondary" id = "dialogCancel" > 取消< / button > < button class = "primary" id = "dialogConfirm" > 优雅关闭< / button > < / div > < / div > < / dialog >
< dialog id = "confirmDialog" aria-labelledby = "dialogTitle" > < div class = "dialog-inner" > < h2 id = "dialogTitle" > 关闭浏览器实例?< / h2 > < p id = "dialogText" > 将请求浏览器正常退出。未保存的网页内容由浏览器自行处理。< / p > < div class = "dialog-warning" id = "dialogWarning" hidden > 强制终止可能造成 profile 数据损坏。仅在浏览器无响应时使用。< / div > < div class = "dialog-actions" > < button class = "secondary" id = "dialogCancel" > 取消< / button > < button class = "primary" id = "dialogConfirm" > 优雅关闭< / button > < / div > < / div > < / dialog >
< dialog class = "edit-dialog" id = "editDialog" aria-labelledby = "editTitle" > < form class = "dialog-inner edit-form" id = "editForm" > < div > < h2 id = "editTitle" > 编辑实例< / h2 > < p id = "editSubtitle" > 修改下次启动使用的保存配置。< / p > < / div > < div class = "field" > < label for = "editName" > 实例名称 < span > *< / span > < / label > < input id = "editName" required > < small > 用于在实例列表中识别此浏览器环境。< / small > < / div > < div class = "field" > < span > 浏览器类型< / span > < div class = "radio-row" role = "radiogroup" aria-label = "浏览器类型" > < label > < input type = "radio" name = "editKind" value = "chrome" id = "editChrome" > Chrome< / label > < label > < input type = "radio" name = "editKind" value = "edge" id = "editEdge" > Edge< / label > < / div > < / div > < div class = "field" > < label for = "editDataDir" > User Data Dir < span > *< / span > < / label > < div class = "path-field" > < input id = "editDataDir" required > < button type = "button" class = "secondary" id = "chooseEditDir" > 选择目录< / button > < / div > < small > 必须是绝对路径;同一目录不能被多个实例同时使用。< / small > < / div > < div class = "field" > < label for = "editURL" > 启动 URL(可选)< / label > < input id = "editURL" type = "url" placeholder = "https://example.com" > < small > 仅支持 http/https;留空时启动浏览器默认页。< / small > < / div > < div class = "field" > < label for = "editPort" > 当前调试端口< / label > < input class = "readonly-value" id = "editPort" readonly > < small > 实际端口由全局起始端口在启动时分配,不能在此固定。< / small > < / div > < div class = "form-note" id = "editReadOnlyNote" hidden > 该实例正在运行或为外部关联。为避免配置与运行身份不一致,浏览器类型和 User Data Dir 已锁定。< / div > < div class = "dialog-actions" > < button type = "button" class = "secondary" id = "editCancel" > 取消< / button > < button type = "submit" class = "primary" id = "editSave" > 保存更改< / button > < / div > < / form > < / dialog >
< dialog id = "discardDialog" aria-labelledby = "discardTitle" > < div class = "dialog-inner" > < h2 id = "discardTitle" > 放弃未保存的更改?< / h2 > < p > 可以先保存配置,或放弃本次编辑并关闭对话框。< / p > < div class = "dialog-actions" > < button class = "secondary" id = "keepEditing" > 继续编辑< / button > < button class = "secondary" id = "discardChanges" > 不保存< / button > < button class = "primary" id = "saveChanges" > 保存< / button > < / div > < / div > < / dialog >
< div class = "toast" id = "toast" role = "status" aria-live = "polite" > < / div >
< div class = "toast" id = "toast" role = "status" aria-live = "polite" > < / div >
< script >
< script >
@@ -277,34 +286,68 @@
] ;
] ;
let selectedId = 'inst-chrome-shop' ;
let selectedId = 'inst-chrome-shop' ;
let modalAction = 'stop' ;
let modalAction = 'stop' ;
let editingID = '' ;
let editSnapshot = null ;
const $ = ( id ) => document . getElementById ( id ) ;
const $ = ( id ) => document . getElementById ( id ) ;
const statusClass = ( s ) => s ;
const statusClass = ( s ) => s ;
function filtered ( ) {
function filtered ( ) {
const q = $ ( 'search' ) . value . trim ( ) . toLowerCase ( ) , browser = $ ( 'browserFilter' ) . value , status = $ ( 'statusFilter' ) . value ;
const q = $ ( 'search' ) . value . trim ( ) . toLowerCase ( ) , browser = $ ( 'browserFilter' ) . value , status = $ ( 'statusFilter' ) . value ;
return instances . filter ( x => ( ! q || [ x . name , x . browser , x . pid , x . port , x . dir ] . join ( ' ' ) . toLowerCase ( ) . includes ( q ) ) && ( browser === 'all' || x . kind === browser ) && ( status === 'all' || x . status === status ) ) ;
return instances . filter ( x => ( ! q || [ x . name , x . browser , x . pid , x . port , x . dir ] . join ( ' ' ) . toLowerCase ( ) . includes ( q ) ) && ( browser === 'all' || x . kind === browser ) && ( status === 'all' || x . status === status ) ) ;
}
}
function selectRow ( id , body ) {
selectedId = id ;
body . querySelectorAll ( 'tr' ) . forEach ( row => row . classList . toggle ( 'selected' , row . dataset . id === id ) ) ;
}
function renderRows ( ) {
function renderRows ( ) {
const rows = filtered ( ) , body = $ ( 'instanceRows' ) ; body . innerHTML = '' ;
const rows = filtered ( ) , body = $ ( 'instanceRows' ) ; body . innerHTML = '' ;
rows . forEach ( x => { const tr = document . createElement ( 'tr' ) ; tr . className = x . id === selectedId ? 'selected' : '' ; tr . tabIndex = 0 ; tr . dataset . id = x . id ; tr . innerHTML = `
rows . forEach ( x => { const tr = document . createElement ( 'tr' ) ; tr . className = x . id === selectedId ? 'selected' : '' ; tr . tabIndex = 0 ; tr . dataset . id = x . id ; tr . title = '双击或按 Enter 编辑实例' ; tr . innerHTML = `
<td><div class="instance-name"><span class="browser-logo ${ x . kind } " aria-hidden="true"> ${ x . kind === 'chrome' ? 'C' : 'E' } </span><div><strong> ${ x . name } </strong><small> ${ x . id } </small></div></div></td>
<td><div class="instance-name"><span class="browser-logo ${ x . kind } " aria-hidden="true"> ${ x . kind === 'chrome' ? 'C' : 'E' } </span><div><strong> ${ x . name } </strong><small> ${ x . id } </small></div></div></td>
<td> ${ x . browser } </td><td class="mono"> ${ x . dir } </td><td class="mono"> ${ x . port || '—' } </td><td><span class="status ${ statusClass ( x . status ) } "> ${ x . statusText } </span></td>
<td> ${ x . browser } </td><td class="mono"> ${ x . dir } </td><td class="mono"> ${ x . port || '—' } </td><td><span class="status ${ statusClass ( x . status ) } "> ${ x . statusText } </span></td>
<td><div class="row-actions"><button data-action="start" aria-label=" ${ x . status === 'associated' ? '重新检测' : '启动' } ${ x . name } "> ${ x . status === 'associated' ? '重新检测' : '启动' } </button></div></td> ` ;
<td><div class="row-actions"><button data-action="edit" aria-label="编辑 ${ x . name } ">编辑</button><button data-action="start" aria-label="${ x . status === 'associated' ? '重新检测' : '启动' } ${ x . name } "> ${ x . status === 'associated' ? '重新检测' : '启动' } </button><button data-action="delete" aria-label="删除 ${ x . name } ">删除</button>< /div></td>` ;
tr . addEventListener ( 'click' , e => { if ( ! e . target . closest ( 'button' ) ) openDetails ( x . id ) ; } ) ;
tr . addEventListener ( 'click' , e => { if ( ! e . target . closest ( 'button' ) ) selectRow ( x . id , body ) ; } ) ;
tr . addEventListener ( 'keydown ' , e => { if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault ( ) ; openDetails ( x . id ) ; } } ) ;
tr . addEventListener ( 'dblclick ' , e => { if ( ! e . target . closest ( 'button' ) ) openEdit ( x . id ) ; } ) ;
tr . querySelectorAll ( 'button' ) . forEach ( b => b . addEventListener ( 'click' , e => { e . stopPropagation ( ) ; selectedId = x . id ; if ( b . dataset . action === 'details' ) openDetails ( x . id ) ; else if ( b . dataset . action === 'stop ' ) openConfirm ( x . id , false ) ; else openConfirm ( x . id , 'start' ) ; } ) ) ;
tr . addEventListener ( 'keydown' , e => { if ( e . key === 'Enter' ) { e . preventDefault ( ) ; openEdit ( x . id ) ; } else if ( e . key === ' ' ) { e . preventDefault ( ) ; selectRow ( x . id , body ) ; } } ) ;
tr . querySelectorAll ( 'button' ) . forEach ( b => b . addEventListener ( 'click' , e => { e . stopPropagation ( ) ; selectRow ( x . id , body ) ; if ( b . dataset . action === 'edit' ) openEdit ( x . id ) ; else if ( b . dataset . action === 'delete' ) showToast ( ` 原型演示:将确认删除“ ${ x . name } ”的 Chub 配置,不会删除 User Data Dir。 ` ) ; else openConfirm ( x . id , 'start' ) ; } ) ) ;
body . appendChild ( tr ) ;
body . appendChild ( tr ) ;
} ) ;
} ) ;
$ ( 'emptyState' ) . style . display = rows . length ? 'none' : 'block' ; $ ( 'resultCount' ) . textContent = ` 显示 ${ rows . length } 个实例 ` ;
$ ( 'emptyState' ) . style . display = rows . length ? 'none' : 'block' ; $ ( 'resultCount' ) . textContent = ` 显示 ${ rows . length } 个实例 ` ;
}
}
function openDetails ( id ) { selectedId = id ; const x = instances . find ( i => i . id === id ) , externallyAssociated = x . status === 'associated' ; $ ( 'drawer' ) . innerHTML = ` <div class="drawer-head"><div><h2> ${ x . name } </h2><p> ${ x . browser } · ${ x . id } </p></div><button class="icon-button" id="closeDrawer" aria-label="关闭详情">× </button></div><div class="drawer-body"><div><span class="status ${ x . status } "> ${ x . statusText } </span></div><div class="detail-card"><div class="detail-line"><span>进程 PID</span><span class="mono"> ${ x . pid } </span></div><div class="detail-line"><span>调试端口</span><span class="mono"> ${ x . port || '—' } </span></div><div class="detail-line"><span>浏览器</span><span> ${ x . browser } </span></div><div class="detail-line"><span>user data dir</span><span class="mono"> ${ x . dir } </span></div><div class="detail-line"><span>启动地址</span><span class="mono"> ${ x . url } </span></div><div class="detail-line"><span>可执行文件</span><span class="mono"> ${ x . executable } </span></div> ${ x . exit ? ` <div class="detail-line"><span>退出结果</span><span> ${ x . exit } </span></div> ` : '' } </div><div class="form-section"><h3>实例操作</h3><div class="drawer-actions" style="border-top:0;padding-top:0;justify-content:flex-start"><button class="secondary" id="restartBtn" ${ x . status === 'starting' || externallyAssociated ? 'disabled' : '' } >↻ 重启</button><button class="danger-button" id="stopBtn" ${ x . status === 'exited' || externallyAssociated ? 'disabled' : '' } >关闭实例</button></div> ${ externallyAssociated ? '<small class="help">外部关联实例仅供观察,Chub 不会接管其生命周期。</small>' : '' } </div><div class="form-section"><h3>最近活动</h3><div class="activity"><div class="activity-item"><i></i><span>状态已同步 · ${ x . started } </span></div><div class="activity-item"><i></i><span> ${ externallyAssociated ? '已验证本地 CDP 端点;未接管外部浏览器。' : x . status === 'occupied' ? '检测到外部进程占用该 user data dir' : '实例配置已通过身份校验' } </span></div></div></div></div> ` ; showDrawer ( ) ; $ ( 'closeDrawer' ) . onclick = hideDrawer ; $ ( 'stopBtn' ) . onclick = ( ) => openConfirm ( x . id , false ) ; $ ( 'restartBtn' ) . onclick = ( ) => openConfirm ( x . id , 'restart' ) ; }
function openDetails ( id ) { selectedId = id ; const x = instances . find ( i => i . id === id ) , externallyAssociated = x . status === 'associated' ; $ ( 'drawer' ) . innerHTML = ` <div class="drawer-head"><div><h2> ${ x . name } </h2><p> ${ x . browser } · ${ x . id } </p></div><button class="icon-button" id="closeDrawer" aria-label="关闭详情">× </button></div><div class="drawer-body"><div><span class="status ${ x . status } "> ${ x . statusText } </span></div><div class="detail-card"><div class="detail-line"><span>进程 PID</span><span class="mono"> ${ x . pid } </span></div><div class="detail-line"><span>调试端口</span><span class="mono"> ${ x . port || '—' } </span></div><div class="detail-line"><span>浏览器</span><span> ${ x . browser } </span></div><div class="detail-line"><span>user data dir</span><span class="mono"> ${ x . dir } </span></div><div class="detail-line"><span>启动地址</span><span class="mono"> ${ x . url } </span></div><div class="detail-line"><span>可执行文件</span><span class="mono"> ${ x . executable } </span></div> ${ x . exit ? ` <div class="detail-line"><span>退出结果</span><span> ${ x . exit } </span></div> ` : '' } </div><div class="form-section"><h3>实例操作</h3><div class="drawer-actions" style="border-top:0;padding-top:0;justify-content:flex-start"><button class="secondary" id="restartBtn" ${ x . status === 'starting' || externallyAssociated ? 'disabled' : '' } >↻ 重启</button><button class="danger-button" id="stopBtn" ${ x . status === 'exited' || externallyAssociated ? 'disabled' : '' } >关闭实例</button></div> ${ externallyAssociated ? '<small class="help">外部关联实例仅供观察,Chub 不会接管其生命周期。</small>' : '' } </div><div class="form-section"><h3>最近活动</h3><div class="activity"><div class="activity-item"><i></i><span>状态已同步 · ${ x . started } </span></div><div class="activity-item"><i></i><span> ${ externallyAssociated ? '已验证本地 CDP 端点;未接管外部浏览器。' : x . status === 'occupied' ? '检测到外部进程占用该 user data dir' : '实例配置已通过身份校验' } </span></div></div></div></div> ` ; showDrawer ( ) ; $ ( 'closeDrawer' ) . onclick = hideDrawer ; $ ( 'stopBtn' ) . onclick = ( ) => openConfirm ( x . id , false ) ; $ ( 'restartBtn' ) . onclick = ( ) => openConfirm ( x . id , 'restart' ) ; }
function editIsLocked ( x ) { return [ 'running' , 'starting' , 'associated' ] . includes ( x . status ) ; }
function editIsDirty ( ) {
if ( ! editingID || ! editSnapshot ) return false ;
return $ ( 'editName' ) . value !== editSnapshot . name || $ ( 'editDataDir' ) . value !== editSnapshot . dir || $ ( 'editURL' ) . value !== editSnapshot . url || document . querySelector ( 'input[name="editKind"]:checked' ) ? . value !== editSnapshot . kind ;
}
function focusEditedRow ( id ) { requestAnimationFrame ( ( ) => document . querySelector ( ` tr[data-id=" ${ id } "] ` ) ? . focus ( ) ) ; }
function finishEdit ( ) { const id = editingID ; $ ( 'editDialog' ) . close ( ) ; editingID = '' ; editSnapshot = null ; focusEditedRow ( id ) ; }
function openEdit ( id ) {
const x = instances . find ( i => i . id === id ) ; if ( ! x ) return ;
selectedId = id ; editingID = id ; editSnapshot = { name : x . name , dir : x . dir , url : x . url || '' , kind : x . kind } ;
const locked = editIsLocked ( x ) ; $ ( 'editName' ) . value = x . name ; $ ( 'editDataDir' ) . value = x . dir ; $ ( 'editURL' ) . value = x . url || '' ; $ ( 'editPort' ) . value = x . port || '—' ; $ ( 'editChrome' ) . checked = x . kind === 'chrome' ; $ ( 'editEdge' ) . checked = x . kind === 'edge' ;
$ ( 'editSubtitle' ) . textContent = locked ? '当前运行状态下可查看配置,但身份约束字段不可修改。' : '修改下次启动使用的保存配置。' ;
$ ( 'editChrome' ) . disabled = locked ; $ ( 'editEdge' ) . disabled = locked ; $ ( 'editDataDir' ) . disabled = locked ; $ ( 'chooseEditDir' ) . disabled = locked ; $ ( 'editReadOnlyNote' ) . hidden = ! locked ;
$ ( 'editDialog' ) . showModal ( ) ; setTimeout ( ( ) => $ ( 'editName' ) . focus ( ) , 0 ) ;
}
function saveEdit ( ) {
const x = instances . find ( i => i . id === editingID ) ; if ( ! x ) return finishEdit ( ) ;
const name = $ ( 'editName' ) . value . trim ( ) , dir = $ ( 'editDataDir' ) . value . trim ( ) , url = $ ( 'editURL' ) . value . trim ( ) ;
if ( ! name || ! dir ) { showToast ( '请填写实例名称和 User Data Dir。' ) ; ( ! name ? $ ( 'editName' ) : $ ( 'editDataDir' ) ) . focus ( ) ; return ; }
const locked = editIsLocked ( x ) ; x . name = name ; x . url = url ;
if ( ! locked ) { x . dir = dir ; x . kind = document . querySelector ( 'input[name="editKind"]:checked' ) ? . value || x . kind ; x . browser = x . kind === 'edge' ? 'Edge' : 'Chrome' ; }
if ( $ ( 'discardDialog' ) . open ) $ ( 'discardDialog' ) . close ( ) ; renderRows ( ) ; const savedName = x . name ; finishEdit ( ) ; showToast ( ` 已保存“ ${ savedName } ”的实例配置。 ` ) ;
}
function requestEditClose ( ) { if ( editIsDirty ( ) ) { $ ( 'editDialog' ) . close ( ) ; $ ( 'discardDialog' ) . showModal ( ) ; return ; } finishEdit ( ) ; }
function openLaunch ( ) { $ ( 'drawer' ) . innerHTML = ` <div class="drawer-head"><div><h2>新建浏览器实例</h2><p>配置完成后,Chub 会在本机启动一个隔离环境。</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭新建实例">× </button></div><form class="drawer-body" id="launchForm"><div class="form-section"><h3>浏览器配置</h3><div class="field"><span>浏览器类型 <span>*</span></span><div class="radio-row" role="radiogroup" aria-label="浏览器类型"><label><input type="radio" name="kind" value="chrome" checked> Chrome</label><label><input type="radio" name="kind" value="edge"> Edge</label></div></div><div class="field"><label for="exe">可执行文件</label><input id="exe" value="自动发现" aria-describedby="exeHelp"><small id="exeHelp">留空时按浏览器类型搜索标准安装路径。</small></div></div><div class="form-section"><h3>运行环境</h3><div class="field"><label for="dataDir">User Data Dir <span>*</span></label><div class="path-field"><input id="dataDir" placeholder="例如:D: \\ Chub \\ profiles \\ new-instance" required><button type="button" class="secondary" id="chooseInstanceDir">选择目录</button></div><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="profile">profile-directory</label><input id="profile" value="Default"></div><div class="field"><label for="url">启动 URL(可选)</label><input id="url" type="url" placeholder="https://example.com"><small>仅支持 http/https;留空时启动浏览器默认页。</small></div><div class="field"><label for="proxy">代理服务器</label><input id="proxy" placeholder="可选,例如 http://127.0.0.1:8080"><small>仅支持无认证代理,认证信息不会被保存。</small></div></div><div class="form-section"><h3>启动选项</h3><label class="check-line"><input type="checkbox" id="headless"> <span><b>无头模式</b><br><small class="help">启动后台浏览器,不显示窗口。</small></span></label><label class="check-line"><input type="checkbox" id="remember" checked> <span><b>保存此配置</b><br><small class="help">只保存路径和非敏感参数。</small></span></label></div><div class="drawer-actions"><button type="button" class="secondary" id="cancelLaunch">取消</button><button type="submit" class="primary">启动实例</button></div></form> ` ; showDrawer ( ) ; $ ( 'closeDrawer' ) . onclick = hideDrawer ; $ ( 'cancelLaunch' ) . onclick = hideDrawer ; $ ( 'chooseInstanceDir' ) . onclick = ( ) => showToast ( '原型演示:将打开目录选择器。' ) ; $ ( 'launchForm' ) . onsubmit = e => { e . preventDefault ( ) ; const data = $ ( 'dataDir' ) ; if ( ! data . value . trim ( ) ) { data . focus ( ) ; data . parentElement . classList . add ( 'error' ) ; const msg = document . createElement ( 'div' ) ; msg . className = 'field-error' ; msg . textContent = '请填写 User Data Dir。' ; data . parentElement . appendChild ( msg ) ; return ; } hideDrawer ( ) ; setTimeout ( ( ) => showToast ( '启动请求已提交,实例进入 STARTING 状态。' ) , 80 ) ; } ; }
function openLaunch ( ) { $ ( 'drawer' ) . innerHTML = ` <div class="drawer-head"><div><h2>新建浏览器实例</h2><p>配置完成后,Chub 会在本机启动一个隔离环境。</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭新建实例">× </button></div><form class="drawer-body" id="launchForm"><div class="form-section"><h3>浏览器配置</h3><div class="field"><span>浏览器类型 <span>*</span></span><div class="radio-row" role="radiogroup" aria-label="浏览器类型"><label><input type="radio" name="kind" value="chrome" checked> Chrome</label><label><input type="radio" name="kind" value="edge"> Edge</label></div></div><div class="field"><label for="exe">可执行文件</label><input id="exe" value="自动发现" aria-describedby="exeHelp"><small id="exeHelp">留空时按浏览器类型搜索标准安装路径。</small></div></div><div class="form-section"><h3>运行环境</h3><div class="field"><label for="dataDir">User Data Dir <span>*</span></label><div class="path-field"><input id="dataDir" placeholder="例如:D: \\ Chub \\ profiles \\ new-instance" required><button type="button" class="secondary" id="chooseInstanceDir">选择目录</button></div><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="profile">profile-directory</label><input id="profile" value="Default"></div><div class="field"><label for="url">启动 URL(可选)</label><input id="url" type="url" placeholder="https://example.com"><small>仅支持 http/https;留空时启动浏览器默认页。</small></div><div class="field"><label for="proxy">代理服务器</label><input id="proxy" placeholder="可选,例如 http://127.0.0.1:8080"><small>仅支持无认证代理,认证信息不会被保存。</small></div></div><div class="form-section"><h3>启动选项</h3><label class="check-line"><input type="checkbox" id="headless"> <span><b>无头模式</b><br><small class="help">启动后台浏览器,不显示窗口。</small></span></label><label class="check-line"><input type="checkbox" id="remember" checked> <span><b>保存此配置</b><br><small class="help">只保存路径和非敏感参数。</small></span></label></div><div class="drawer-actions"><button type="button" class="secondary" id="cancelLaunch">取消</button><button type="submit" class="primary">启动实例</button></div></form> ` ; showDrawer ( ) ; $ ( 'closeDrawer' ) . onclick = hideDrawer ; $ ( 'cancelLaunch' ) . onclick = hideDrawer ; $ ( 'chooseInstanceDir' ) . onclick = ( ) => showToast ( '原型演示:将打开目录选择器。' ) ; $ ( 'launchForm' ) . onsubmit = e => { e . preventDefault ( ) ; const data = $ ( 'dataDir' ) ; if ( ! data . value . trim ( ) ) { data . focus ( ) ; data . parentElement . classList . add ( 'error' ) ; const msg = document . createElement ( 'div' ) ; msg . className = 'field-error' ; msg . textContent = '请填写 User Data Dir。' ; data . parentElement . appendChild ( msg ) ; return ; } hideDrawer ( ) ; setTimeout ( ( ) => showToast ( '启动请求已提交,实例进入 STARTING 状态。' ) , 80 ) ; } ; }
function showDrawer ( ) { $ ( 'drawer' ) . classList . add ( 'open' ) ; $ ( 'backdrop' ) . classList . add ( 'open' ) ; $ ( 'drawer' ) . setAttribute ( 'aria-hidden' , 'false' ) ; setTimeout ( ( ) => $ ( 'drawer' ) . querySelector ( 'button, input, select' ) ? . focus ( ) , 60 ) ; }
function showDrawer ( ) { $ ( 'drawer' ) . classList . add ( 'open' ) ; $ ( 'backdrop' ) . classList . add ( 'open' ) ; $ ( 'drawer' ) . setAttribute ( 'aria-hidden' , 'false' ) ; setTimeout ( ( ) => $ ( 'drawer' ) . querySelector ( 'button, input, select' ) ? . focus ( ) , 60 ) ; }
function hideDrawer ( ) { $ ( 'drawer' ) . classList . remove ( 'open' ) ; $ ( 'backdrop' ) . classList . remove ( 'open' ) ; $ ( 'drawer' ) . setAttribute ( 'aria-hidden' , 'true' ) ; }
function hideDrawer ( ) { $ ( 'drawer' ) . classList . remove ( 'open' ) ; $ ( 'backdrop' ) . classList . remove ( 'open' ) ; $ ( 'drawer' ) . setAttribute ( 'aria-hidden' , 'true' ) ; }
function openConfirm ( id , action ) { const x = instances . find ( i => i . id === id ) ; modalAction = action ; $ ( 'dialogTitle' ) . textContent = action === 'restart' ? '重启浏览器实例?' : action === 'start' ? '启动浏览器实例?' : '关闭浏览器实例?' ; $ ( 'dialogText' ) . textContent = action === 'restart' ? ` 将先请求“ ${ x . name } ”正常退出,再使用相同配置重新启动。 ` : action === 'start' ? ` 将使用已保存配置启动“ ${ x . name } ”。 ` : ` 将请求“ ${ x . name } ”正常退出。未保存的网页内容由浏览器自行处理。 ` ; $ ( 'dialogWarning' ) . hidden = action !== false ; $ ( 'dialogConfirm' ) . textContent = action === false ? '优雅关闭' : action === 'restart' ? '确认重启' : '启动实例' ; $ ( 'dialogConfirm' ) . className = action === false ? 'danger-button' : 'primary' ; $ ( 'confirmDialog' ) . showModal ( ) ; }
function openConfirm ( id , action ) { const x = instances . find ( i => i . id === id ) ; modalAction = action ; $ ( 'dialogTitle' ) . textContent = action === 'restart' ? '重启浏览器实例?' : action === 'start' ? '启动浏览器实例?' : '关闭浏览器实例?' ; $ ( 'dialogText' ) . textContent = action === 'restart' ? ` 将先请求“ ${ x . name } ”正常退出,再使用相同配置重新启动。 ` : action === 'start' ? ` 将使用已保存配置启动“ ${ x . name } ”。 ` : ` 将请求“ ${ x . name } ”正常退出。未保存的网页内容由浏览器自行处理。 ` ; $ ( 'dialogWarning' ) . hidden = action !== false ; $ ( 'dialogConfirm' ) . textContent = action === false ? '优雅关闭' : action === 'restart' ? '确认重启' : '启动实例' ; $ ( 'dialogConfirm' ) . className = action === false ? 'danger-button' : 'primary' ; $ ( 'confirmDialog' ) . showModal ( ) ; }
function showToast ( message ) { const toast = $ ( 'toast' ) ; toast . textContent = message ; toast . classList . add ( 'show' ) ; clearTimeout ( showToast . timer ) ; showToast . timer = setTimeout ( ( ) => toast . classList . remove ( 'show' ) , 3600 ) ; }
function showToast ( message ) { const toast = $ ( 'toast' ) ; toast . textContent = message ; toast . classList . add ( 'show' ) ; clearTimeout ( showToast . timer ) ; showToast . timer = setTimeout ( ( ) => toast . classList . remove ( 'show' ) , 3600 ) ; }
$ ( 'dialogCancel' ) . onclick = ( ) => $ ( 'confirmDialog' ) . close ( ) ; $ ( 'confirmDialog' ) . addEventListener ( 'cancel' , e => { e . preventDefault ( ) ; $ ( 'confirmDialog' ) . close ( ) ; } ) ; $ ( 'dialogConfirm' ) . onclick = ( ) => { $ ( 'confirmDialog' ) . close ( ) ; const message = modalAction === false ? '关闭请求已提交,实例将进入 STOPPING。' : modalAction === 'restart' ? '重启请求已提交,正在等待旧实例退出。' : '启动请求已提交,实例将进入 STARTING。' ; setTimeout ( ( ) => showToast ( message ) , 80 ) ; } ;
$ ( 'dialogCancel' ) . onclick = ( ) => $ ( 'confirmDialog' ) . close ( ) ; $ ( 'confirmDialog' ) . addEventListener ( 'cancel' , e => { e . preventDefault ( ) ; $ ( 'confirmDialog' ) . close ( ) ; } ) ; $ ( 'dialogConfirm' ) . onclick = ( ) => { $ ( 'confirmDialog' ) . close ( ) ; const message = modalAction === false ? '关闭请求已提交,实例将进入 STOPPING。' : modalAction === 'restart' ? '重启请求已提交,正在等待旧实例退出。' : '启动请求已提交,实例将进入 STARTING。' ; setTimeout ( ( ) => showToast ( message ) , 80 ) ; } ;
function setPage ( page ) { const settings = page === 'settings' ; $ ( 'instancesPage' ) . classList . toggle ( 'page-hidden' , settings ) ; $ ( 'settingsPage' ) . classList . toggle ( 'page-hidden' , ! settings ) ; $ ( 'pageEyebrow' ) . textContent = settings ? 'Application set tings ' : 'Browser instances' ; $ ( 'pageTitle' ) . textContent = settings ? '设置' : '浏览器实例 ' ; $ ( 'pageSubtitle' ) . textContent = settings ? '管理浏览器路径、默认目录和本地运行行为' : '启动、观察并安全管理 Chrome 与 Edge 环境 ' ; $ ( 'newInstance' ) . classList . toggle ( 'page-hidden' , settings ) ; if ( settings ) $ ( 'chromePath' ) . focus ( ) ; }
function refreshInstances ( ) { const button = $ ( 'refresh' ) ; if ( button . disabled ) return ; button . disabled = true ; button . textContent = '↻ 刷新中…' ; setTimeout ( ( ) => { const starting = instances . find ( x => x . status === 'star ting') ; if ( starting ) { starting . status = 'running' ; starting . statusText = '运行中(托管)' ; starting . pid = '18468' ; starting . port = '9667 ' ; } renderRows ( ) ; button . disabled = false ; button . textContent = '↻ 刷新状态 ' ; document . querySelector ( '.table-footer span:last-child' ) . textContent = '最后检查:刚刚' ; showToast ( '已检查保存的实例状态;未扫描其他 Chrome 或 Edge。' ) ; } , 900 ) ; }
$ ( 'newInstance' ) . onclick = openLaunch ; $ ( 'backdrop' ) . onclick = hideDrawer ; $ ( 'search' ) . oninput = renderRows ; $ ( 'browserFilter' ) . onchange = renderRows ; $ ( 'statusFilter' ) . onchange = renderRows ; $ ( 'refresh' ) . onclick = ( ) => { $ ( 'refresh' ) . textContent = '✓ 已刷新' ; setTimeout ( ( ) => $ ( 'refresh' ) . textContent = '↻ 刷新状态' , 1200 ) ; } ; $ ( 'themeTogg le' ) . onclick = ( ) => { const dark = document . documentElement . dataset . theme === 'dark' ; document . documentElement . dataset . theme = dark ? '' : 'dark ' ; $ ( 'themeTogg le' ) . textContent = dark ? '☾' : '☀' ; $ ( 'themeToggle ') . setAttribute ( 'aria-label' , dark ? '切换深色主题' : '切换浅色主题' ) ; } ;
function setPage ( page ) { const settings = page === 'settings' ; $ ( 'instancesPage' ) . classList . toggle ( 'page-hidden' , settings ) ; $ ( 'settingsPage' ) . classList . toggle ( 'page-hidden' , ! settings ) ; $ ( 'pageEyebrow' ) . textContent = settings ? 'Application settings' : 'Browser instances' ; $ ( 'pageTit le' ) . textContent = settings ? '设置 ' : '浏览器实例 ' ; $ ( 'pageSubtit le' ) . textContent = settings ? '管理浏览器路径、默认目录和本地运行行为' : '启动、观察并安全管理 Chrome 与 Edge 环境' ; $ ( 'instanceCommands ') . classList . toggle ( 'page-hidden' , settings ) ; if ( settings ) $ ( 'chromePath' ) . focus ( ) ; }
$ ( 'newInstance' ) . onclick = openLaunch ; $ ( 'backdrop' ) . onclick = hideDrawer ; $ ( 'search' ) . oninput = renderRows ; $ ( 'browserFilter' ) . onchange = renderRows ; $ ( 'statusFilter' ) . onchange = renderRows ; $ ( 'refresh' ) . onclick = refreshInstances ; $ ( 'themeToggle' ) . onclick = ( ) => { const dark = document . documentElement . dataset . theme === 'dark' ; document . documentElement . dataset . theme = dark ? '' : 'dark' ; $ ( 'themeToggle' ) . textContent = dark ? '☾' : '☀' ; $ ( 'themeToggle' ) . setAttribute ( 'aria-label' , dark ? '切换深色主题' : '切换浅色主题' ) ; } ;
$ ( 'editCancel' ) . onclick = requestEditClose ; $ ( 'chooseEditDir' ) . onclick = ( ) => showToast ( '原型演示:将打开目录选择器并回填 User Data Dir。' ) ; $ ( 'editForm' ) . addEventListener ( 'submit' , e => { e . preventDefault ( ) ; saveEdit ( ) ; } ) ; $ ( 'editDialog' ) . addEventListener ( 'cancel' , e => { e . preventDefault ( ) ; requestEditClose ( ) ; } ) ; $ ( 'keepEditing' ) . onclick = ( ) => { $ ( 'discardDialog' ) . close ( ) ; $ ( 'editDialog' ) . showModal ( ) ; setTimeout ( ( ) => $ ( 'editName' ) . focus ( ) , 0 ) ; } ; $ ( 'discardChanges' ) . onclick = ( ) => { $ ( 'discardDialog' ) . close ( ) ; finishEdit ( ) ; } ; $ ( 'saveChanges' ) . onclick = saveEdit ;
document . addEventListener ( 'keydown' , e => { if ( e . key === 'F5' && ! $ ( 'editDialog' ) . open && ! $ ( 'discardDialog' ) . open ) { e . preventDefault ( ) ; refreshInstances ( ) ; } } ) ;
document . querySelectorAll ( '[data-nav]' ) . forEach ( b => b . onclick = ( ) => { document . querySelectorAll ( '[data-nav]' ) . forEach ( n => n . removeAttribute ( 'aria-current' ) ) ; b . setAttribute ( 'aria-current' , 'page' ) ; setPage ( b . dataset . nav ) ; } ) ;
document . querySelectorAll ( '[data-nav]' ) . forEach ( b => b . onclick = ( ) => { document . querySelectorAll ( '[data-nav]' ) . forEach ( n => n . removeAttribute ( 'aria-current' ) ) ; b . setAttribute ( 'aria-current' , 'page' ) ; setPage ( b . dataset . nav ) ; } ) ;
document . querySelectorAll ( '.browse-setting' ) . forEach ( b => b . onclick = ( ) => showToast ( ` 原型演示:将打开 ${ b . dataset . target . includes ( 'Path' ) ? '文件' : '目录' } 选择器。 ` ) ) ;
document . querySelectorAll ( '.browse-setting' ) . forEach ( b => b . onclick = ( ) => showToast ( ` 原型演示:将打开 ${ b . dataset . target . includes ( 'Path' ) ? '文件' : '目录' } 选择器。 ` ) ) ;
document . querySelectorAll ( '.search-setting' ) . forEach ( b => b . onclick = ( ) => { if ( b . dataset . timer ) { clearTimeout ( Number ( b . dataset . timer ) ) ; delete b . dataset . timer ; b . textContent = '搜索' ; showToast ( '已取消当前搜索。' ) ; return ; } b . textContent = '取消' ; showToast ( '正在搜索可用路径…' ) ; b . dataset . timer = String ( setTimeout ( ( ) => { delete b . dataset . timer ; b . textContent = '搜索' ; showToast ( '搜索完成:已更新当前字段。' ) ; } , 1600 ) ) ; } ) ;
document . querySelectorAll ( '.search-setting' ) . forEach ( b => b . onclick = ( ) => { if ( b . dataset . timer ) { clearTimeout ( Number ( b . dataset . timer ) ) ; delete b . dataset . timer ; b . textContent = '搜索' ; showToast ( '已取消当前搜索。' ) ; return ; } b . textContent = '取消' ; showToast ( '正在搜索可用路径…' ) ; b . dataset . timer = String ( setTimeout ( ( ) => { delete b . dataset . timer ; b . textContent = '搜索' ; showToast ( '搜索完成:已更新当前字段。' ) ; } , 1600 ) ) ; } ) ;