@@ -262,6 +262,7 @@
< div class = "field" > < label for = "defaultDataDir" > 默认 user data dir 根目录< / label > < div class = "path-field" > < input id = "defaultDataDir" value = "D:\\Chub\\profiles" > < button type = "button" class = "secondary browse-setting" data-target = "defaultDataDir" > 选择目录< / button > < button type = "button" class = "secondary search-setting" data-target = "defaultDataDir" > 搜索< / button > < / div > < small > 新建实例时作为默认建议路径;不会移动或删除已有 profile。< / small > < / div >
< div class = "field" > < label for = "logDir" > 日志目录< / label > < div class = "path-field" > < input id = "logDir" value = "D:\\Chub\\logs" > < button type = "button" class = "secondary browse-setting" data-target = "logDir" > 选择目录< / button > < button type = "button" class = "secondary search-setting" data-target = "logDir" > 搜索< / button > < / div > < small > 日志仅保存必要诊断信息,并过滤敏感参数。< / small > < / div >
< / div >
< div class = "form-section" > < h3 > 代理< / h3 > < p class = "help" > 仅保存无认证代理;名称可在实例表单中选择。< / p > < div class = "field" > < label for = "proxyName" > 代理名称< / label > < input id = "proxyName" placeholder = "例如:新加坡出口" > < / div > < div class = "field" > < label for = "proxyServer" > 代理地址< / label > < input id = "proxyServer" placeholder = "例如:http://127.0.0.1:8080" > < small > 支持 http、https、socks4、socks5 的 < span class = "mono" > scheme://host:port< / span > ;不允许用户名、密码或路径。< / small > < / div > < div class = "dialog-actions" > < button type = "button" class = "secondary" id = "proxyCancel" hidden > 取消编辑< / button > < button type = "button" class = "secondary" id = "proxySave" > 添加代理< / button > < / div > < div class = "setting-list" id = "proxyList" aria-label = "已保存代理" > < / div > < / div >
< div class = "settings-actions" > < button type = "button" class = "secondary" id = "discardSettings" > 取消< / button > < button type = "submit" class = "primary" > 保存设置< / button > < / div >
< / form >
< aside class = "settings-card" > < div > < h2 > 运行行为< / h2 > < p > 影响后续启动和应用关闭时的默认行为。< / p > < / div > < div class = "setting-list" > < label class = "check-line" > < input type = "checkbox" id = "closeOnExit" > < span > < b > 退出 Chub 时关闭本工具启动的实例< / b > < br > < small class = "help" > 默认关闭。不会影响外部浏览器。< / small > < / span > < / label > < div class = "field" > < label for = "remoteDebugPort" > 远程调试起始端口< / label > < input id = "remoteDebugPort" type = "number" min = "1024" max = "65535" value = "9666" inputmode = "numeric" > < small > 留空时使用 9666。启动实例仅绑定 127.0.0.1;端口被占用时顺序尝试下一个可用端口。< / small > < / div > < div class = "settings-status" id = "settingsStatus" > 设置已保存 · 最后更新:刚刚< / div > < / div > < / aside >
@@ -273,13 +274,17 @@
< div class = "drawer-backdrop" id = "backdrop" > < / div >
< 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 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 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 = "editProxy" > 代理 < / label > < select id = "editProxy" > < option value = "" > 无代理 < / option > < / select > < small > 选择已保存的无认证代理;修改后下次启动生效。 < / 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 >
< script >
const proxies = [
{ id : 'proxy-sg' , name : '新加坡出口' , server : 'http://127.0.0.1:8080' } ,
{ id : 'proxy-test' , name : '测试 SOCKS5' , server : 'socks5://127.0.0.1:1080' }
] ;
const instances = [
{ id : 'inst-chrome-shop' , name : 'Shopee · SG 主店' , kind : 'chrome' , browser : 'Chrome' , status : 'running' , statusText : '运行中(托管)' , pid : '18420' , port : '9666' , dir : 'D:\\Chub\\profiles\\shopee-sg' , started : '今天 09:42' , url : 'https://seller.shopee.sg/' , executable : 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' } ,
{ id : 'inst-chrome-shop' , name : 'Shopee · SG 主店' , kind : 'chrome' , browser : 'Chrome' , status : 'running' , statusText : '运行中(托管)' , pid : '18420' , port : '9666' , dir : 'D:\\Chub\\profiles\\shopee-sg' , started : '今天 09:42' , url : 'https://seller.shopee.sg/' , proxyId : 'proxy-sg' , executable : 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' } ,
{ id : 'inst-edge-review' , name : '素材审核 · Edge' , kind : 'edge' , browser : 'Edge' , status : 'starting' , statusText : '启动中' , pid : '—' , port : '检测中…' , dir : 'D:\\Chub\\profiles\\asset-review' , started : '正在启动' , url : 'https://example.com/review' , executable : 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' } ,
{ id : 'inst-chrome-ph' , name : 'Shopee · PH 备用' , kind : 'chrome' , browser : 'Chrome' , status : 'associated' , statusText : '外部已关联' , pid : '16108' , port : '9668' , dir : 'D:\\Chub\\profiles\\shopee-ph' , started : '昨天 18:16' , url : 'https://seller.shopee.ph/' , executable : 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' } ,
{ id : 'inst-edge-test' , name : '测试环境 · Edge' , kind : 'edge' , browser : 'Edge' , status : 'exited' , statusText : '已退出' , pid : '—' , port : '—' , dir : 'D:\\Chub\\profiles\\edge-test' , started : '昨天 16:04' , url : 'https://localhost:3000' , executable : 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' , exit : '退出码 0' }
@@ -288,8 +293,12 @@
let modalAction = 'stop' ;
let editingID = '' ;
let editSnapshot = null ;
let editingProxyID = '' ;
const $ = ( id ) => document . getElementById ( id ) ;
const statusClass = ( s ) => s ;
const proxyLabel = ( id ) => proxies . find ( p => p . id === id ) ? . name || '无代理' ;
function renderProxyOptions ( select , selected = '' ) { select . innerHTML = '<option value="">无代理</option>' + proxies . map ( p => ` <option value=" ${ p . id } "> ${ p . name } · ${ p . server } </option> ` ) . join ( '' ) ; select . value = selected ; }
function renderProxies ( ) { const list = $ ( 'proxyList' ) ; list . innerHTML = proxies . map ( p => { const uses = instances . filter ( i => i . proxyId === p . id ) . length ; return ` <div class="detail-line"><span><b> ${ p . name } </b><br><small class="mono"> ${ p . server } </small></span><span><small> ${ uses ? ` 已被 ${ uses } 个实例使用 ` : '未使用' } </small> <button type="button" class="secondary" data-proxy-edit=" ${ p . id } ">编辑</button><button type="button" class="secondary" data-proxy-delete=" ${ p . id } ">删除</button></span></div> ` ; } ) . join ( '' ) || '<small class="help">尚无代理。实例将以无代理方式启动。</small>' ; list . querySelectorAll ( '[data-proxy-edit]' ) . forEach ( b => b . onclick = ( ) => { const p = proxies . find ( item => item . id === b . dataset . proxyEdit ) ; editingProxyID = p . id ; $ ( 'proxyName' ) . value = p . name ; $ ( 'proxyServer' ) . value = p . server ; $ ( 'proxySave' ) . textContent = '保存代理' ; $ ( 'proxyCancel' ) . hidden = false ; $ ( 'proxyName' ) . focus ( ) ; } ) ; list . querySelectorAll ( '[data-proxy-delete]' ) . forEach ( b => b . onclick = ( ) => { const p = proxies . find ( item => item . id === b . dataset . proxyDelete ) , uses = instances . filter ( i => i . proxyId === p . id ) . length ; if ( uses ) return showToast ( ` “ ${ p . name } ”仍被 ${ uses } 个实例使用,不能删除。 ` ) ; proxies . splice ( proxies . indexOf ( p ) , 1 ) ; renderProxies ( ) ; showToast ( ` 已删除代理“ ${ p . name } ”。 ` ) ; } ) ; }
function filtered ( ) {
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 ) ) ;
@@ -303,11 +312,11 @@
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> ${ 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="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> ` ;
<td><div class="row-actions"><button data-action="edit" aria-label="编辑 ${ x . name } ">编辑</button><button data-action="start" aria-label=" ${ x . status === 'associated' ? '重新检测' : x . status === 'running' ? '停止' : '启动' } ${ x . name } "> ${ x . status === 'associated' ? '重新检测' : x . status === 'running' ? '■ 停止' : '▶ 启动'} </button><button data-action="delete" aria-label="删除 ${ x . name } ">删除</button></div></td> ` ;
tr . addEventListener ( 'click' , e => { if ( ! e . target . closest ( 'button' ) ) selectRow ( x . id , body ) ; } ) ;
tr . addEventListener ( 'dblclick' , e => { if ( ! e . target . closest ( 'button' ) ) openEdit ( x . id ) ; } ) ;
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' ) ; } ) ) ;
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 if ( x . status === 'running' ) { x . status = 'stopping' ; x . statusText = '停止中' ; renderRows ( ) ; setTimeout ( ( ) => { x . status = 'exited' ; x . statusText = '已退出' ; x . pid = '—' ; x . port = '—' ; renderRows ( ) ; showToast ( ` “ ${ x . name } ”已优雅退出。 ` ) ; } , 700 ) ; } else openConfirm ( x . id , 'start' ) ; } ) ) ;
body . appendChild ( tr ) ;
} ) ;
$ ( 'emptyState' ) . style . display = rows . length ? 'none' : 'block' ; $ ( 'resultCount' ) . textContent = ` 显示 ${ rows . length } 个实例 ` ;
@@ -316,14 +325,14 @@
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 ;
return $ ( 'editName' ) . value !== editSnapshot . name || $ ( 'editDataDir' ) . value !== editSnapshot . dir || $ ( 'editURL' ) . value !== editSnapshot . url || $ ( 'editProxy' ) . value !== editSnapshot . proxyId || 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' ;
selectedId = id ; editingID = id ; editSnapshot = { name : x . name , dir : x . dir , url : x . url || '' , kind : x . kind , proxyId : x . proxyId || '' } ;
const locked = editIsLocked ( x ) ; $ ( 'editName' ) . value = x . name ; $ ( 'editDataDir' ) . value = x . dir ; $ ( 'editURL' ) . value = x . url || '' ; renderProxyOptions ( $ ( 'editProxy' ) , x . proxyId || '' ) ; $ ( '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 ) ;
@@ -332,12 +341,12 @@
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 ;
const locked = editIsLocked ( x ) ; x . name = name ; x . url = url ; x . proxyId = $ ( 'editProxy' ) . value ;
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="p roxy">代理服务器 </label><inpu t 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="launchP roxy">代理</label><selec t id="launchProxy"><option value="">无代理</option></select><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 ( ) ; renderProxyOptions ( $ ( 'launchProxy' ) ) ; $ ( '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 ; } const proxy = proxyLabel ( $ ( 'launchProxy' ) . value ) ; hideDrawer ( ) ; setTimeout ( ( ) => showToast ( ` 启动请求已提交( ${ proxy } ) ,实例进入 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 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 ( ) ; }
@@ -351,9 +360,11 @@
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 ( '.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 ) ) ; } ) ;
$ ( 'proxySave' ) . onclick = ( ) => { const name = $ ( 'proxyName' ) . value . trim ( ) , server = $ ( 'proxyServer' ) . value . trim ( ) ; if ( ! name || ! server || ! /^(https?|socks[45]):\/\/[^\/@?#:]+(?::\d+)$/ . test ( server ) ) { showToast ( '请填写名称和无认证的 scheme://host:port 代理地址。' ) ; return ; } if ( proxies . some ( p => p . name === name && p . id !== editingProxyID ) ) { showToast ( '代理名称已存在,请使用不同名称。' ) ; return ; } if ( editingProxyID ) { const p = proxies . find ( item => item . id === editingProxyID ) ; p . name = name ; p . server = server ; } else proxies . push ( { id : ` proxy- ${ Date . now ( ) } ` , name , server } ) ; editingProxyID = '' ; $ ( 'proxyName' ) . value = '' ; $ ( 'proxyServer' ) . value = '' ; $ ( 'proxySave' ) . textContent = '添加代理' ; $ ( 'proxyCancel' ) . hidden = true ; renderProxies ( ) ; showToast ( '代理已保存;后续启动将使用最新端点。' ) ; } ;
$ ( 'proxyCancel' ) . onclick = ( ) => { editingProxyID = '' ; $ ( 'proxyName' ) . value = '' ; $ ( 'proxyServer' ) . value = '' ; $ ( 'proxySave' ) . textContent = '添加代理' ; $ ( 'proxyCancel' ) . hidden = true ; } ;
$ ( 'settingsForm' ) . addEventListener ( 'submit' , e => { e . preventDefault ( ) ; $ ( 'settingsStatus' ) . textContent = '设置已保存 · 最后更新:刚刚' ; $ ( 'settingsStatus' ) . classList . remove ( 'warning' ) ; showToast ( '设置已保存,后续新建实例将使用新的默认值。' ) ; } ) ;
$ ( 'discardSettings' ) . onclick = ( ) => { $ ( 'settingsForm' ) . reset ( ) ; $ ( 'settingsStatus' ) . textContent = '已取消本次修改,恢复到上次保存值。' ; $ ( 'settingsStatus' ) . classList . add ( 'warning' ) ; showToast ( '已取消设置修改。' ) ; } ;
renderRows ( ) ;
renderRows ( ) ; renderProxies ( ) ;
< / script >
< / body >
< / html >