2026-07-22 14:51:11 +08:00
<!doctype html>
< html lang = "zh-CN" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > Chub · 浏览器实例管理原型</ title >
< style >
: root {
color-scheme : light ;
--bg : #f5f7fb ;
--surface : #ffffff ;
--surface-2 : #f8f9fc ;
--surface-3 : #eef2f8 ;
--text : #18202b ;
--muted : #617083 ;
--line : #dce2eb ;
--line-strong : #c7d0dd ;
--accent : #2563eb ;
--accent-strong : #1d4ed8 ;
--accent-soft : #e7efff ;
--success : #087443 ;
--success-soft : #e4f6ed ;
--warning : #9a5b00 ;
--warning-soft : #fff3d8 ;
--danger : #b42318 ;
--danger-soft : #ffebe8 ;
--shadow : 0 18 px 48 px rgba ( 26 , 43 , 73 , .14 ), 0 2 px 7 px rgba ( 26 , 43 , 73 , .08 );
--radius-control : 8 px ;
--radius-card : 14 px ;
--radius-dialog : 16 px ;
--focus : 0 0 0 3 px rgba ( 37 , 99 , 235 , .25 );
}
[ data-theme = "dark" ] {
color-scheme : dark ;
--bg : #11151c ;
--surface : #1b222d ;
--surface-2 : #202936 ;
--surface-3 : #293544 ;
--text : #f1f5fb ;
--muted : #aab7c8 ;
--line : #364354 ;
--line-strong : #4a5a6c ;
--accent : #83aaff ;
--accent-strong : #a9c2ff ;
--accent-soft : #263b66 ;
--success : #65d49a ;
--success-soft : #173c2b ;
--warning : #f6c15c ;
--warning-soft : #4a3612 ;
--danger : #ff9c91 ;
--danger-soft : #4b2421 ;
--shadow : 0 18 px 48 px rgba ( 0 , 0 , 0 , .35 );
}
* { box-sizing : border-box ; }
html , body { margin : 0 ; min-height : 100 % ; font-family : "Segoe UI Variable" , "Segoe UI" , system-ui , sans-serif ; color : var ( -- text ); background : var ( -- bg ); }
body { font-size : 14 px ; }
button , input , select { font : inherit ; }
button { border : 0 ; cursor : pointer ; }
button : focus-visible , input : focus-visible , select : focus-visible { outline : none ; box-shadow : var ( -- focus ); }
. prototype-ribbon { position : fixed ; z-index : 20 ; top : 0 ; left : 50 % ; transform : translateX ( -50 % ); background : #173b72 ; color : #fff ; padding : 5 px 14 px ; border-radius : 0 0 8 px 8 px ; font-size : 11 px ; letter-spacing : .04 em ; }
. app { display : grid ; grid-template-columns : 232 px minmax ( 0 , 1 fr ); min-height : 100 vh ; }
. sidebar { background : var ( -- surface ); border-right : 1 px solid var ( -- line ); padding : 34 px 14 px 18 px ; display : flex ; flex-direction : column ; gap : 24 px ; }
. brand { display : flex ; align-items : center ; gap : 10 px ; padding : 0 10 px ; }
. brand-mark { width : 32 px ; height : 32 px ; border-radius : 10 px ; background : linear-gradient ( 135 deg , #2563eb , #66a1ff ); color : #fff ; display : grid ; place-items : center ; font-weight : 800 ; letter-spacing : -.04 em ; }
. brand strong { display : block ; font-size : 17 px ; letter-spacing : -.02 em ; }
. brand span { color : var ( -- muted ); font-size : 11 px ; }
. nav { display : grid ; gap : 4 px ; }
. nav button { min-height : 40 px ; padding : 0 12 px ; text-align : left ; color : var ( -- muted ); background : transparent ; border-radius : var ( -- radius - control ); display : flex ; align-items : center ; gap : 11 px ; }
. nav button : hover { background : var ( -- surface -2 ); color : var ( -- text ); }
. nav button [ aria-current = "page" ] { background : var ( -- accent - soft ); color : var ( -- accent - strong ); font-weight : 650 ; }
. nav-icon { width : 18 px ; text-align : center ; font-size : 16 px ; }
. sidebar-footer { margin-top : auto ; border-top : 1 px solid var ( -- line ); padding : 16 px 10 px 0 ; color : var ( -- muted ); font-size : 12 px ; line-height : 1.5 ; }
. main { min-width : 0 ; padding : 34 px clamp ( 18 px , 3 vw , 42 px ) 38 px ; }
. topbar { display : flex ; align-items : flex-start ; justify-content : space-between ; gap : 18 px ; max-width : 1420 px ; margin : 0 auto 24 px ; }
. 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 ; }
. subtitle { margin : 0 ; color : var ( -- muted ); }
2026-07-25 16:21:38 +08:00
. 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 % ; }
2026-07-22 14:51:11 +08:00
. 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 { width : 38 px ; padding : 0 ; }
. secondary : hover , . icon-button : hover { background : var ( -- surface -2 ); }
. primary { color : #fff ; background : var ( -- accent ); }
. primary : hover { background : var ( -- accent - strong ); }
. danger-button { color : #fff ; background : var ( -- danger ); }
. danger-button : hover { filter : brightness ( .94 ); }
. content { max-width : 1420 px ; margin : auto ; display : grid ; gap : 18 px ; }
. banner { display : flex ; gap : 12 px ; align-items : flex-start ; background : var ( -- warning - soft ); border : 1 px solid color-mix ( in srgb , var ( -- warning ) 30 % , transparent ); border-radius : var ( -- radius - card ); padding : 13 px 16 px ; color : var ( -- text ); }
. banner b { color : var ( -- warning ); }
. banner p { margin : 3 px 0 0 ; color : var ( -- muted ); font-size : 13 px ; }
. summary-grid { display : grid ; grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr )); gap : 12 px ; }
. summary { background : var ( -- surface ); border : 1 px solid var ( -- line ); border-radius : var ( -- radius - card ); padding : 16 px ; }
. summary-label { color : var ( -- muted ); font-size : 12 px ; }
. summary-value { margin-top : 8 px ; display : flex ; align-items : baseline ; gap : 8 px ; font-size : 25 px ; font-weight : 750 ; letter-spacing : -.03 em ; }
. summary-note { color : var ( -- muted ); font-size : 12 px ; }
. workspace { background : var ( -- surface ); border : 1 px solid var ( -- line ); border-radius : var ( -- radius - card ); overflow : hidden ; }
. toolbar { padding : 14 px 16 px ; border-bottom : 1 px solid var ( -- line ); display : flex ; flex-wrap : wrap ; align-items : center ; gap : 9 px ; }
. search { min-width : 220 px ; flex : 1 ; position : relative ; }
. search input { width : 100 % ; height : 36 px ; border : 1 px solid var ( -- line - strong ); border-radius : var ( -- radius - control ); background : var ( -- surface -2 ); padding : 0 12 px 0 35 px ; color : var ( -- text ); }
. search span { position : absolute ; left : 12 px ; top : 8 px ; color : var ( -- muted ); }
select { height : 36 px ; color : var ( -- text ); background : var ( -- surface ); border : 1 px solid var ( -- line - strong ); border-radius : var ( -- radius - control ); padding : 0 30 px 0 11 px ; }
. toolbar-spacer { flex : 1 ; }
. table-wrap { overflow-x : auto ; }
table { width : 100 % ; border-collapse : collapse ; min-width : 760 px ; }
th { height : 42 px ; padding : 0 16 px ; text-align : left ; color : var ( -- muted ); font-size : 12 px ; font-weight : 650 ; background : var ( -- surface -2 ); border-bottom : 1 px solid var ( -- line ); }
td { padding : 14 px 16 px ; border-bottom : 1 px solid var ( -- line ); vertical-align : middle ; }
tbody tr { cursor : pointer ; }
tbody tr : hover { background : var ( -- surface -2 ); }
tbody tr . selected { background : var ( -- accent - soft ); }
. instance-name { display : flex ; align-items : center ; gap : 10 px ; min-width : 190 px ; }
. browser-logo { width : 28 px ; height : 28 px ; display : grid ; place-items : center ; border-radius : 8 px ; color : #fff ; font-size : 12 px ; font-weight : 800 ; }
. chrome { background : conic-gradient ( #e84c3d 0 33 % , #f5c746 0 66 % , #36a269 0 ); }
. edge { background : linear-gradient ( 140 deg , #0c9b9e , #1768d2 ); }
. instance-name strong { display : block ; font-size : 14 px ; }
. instance-name small , . mono { color : var ( -- muted ); font-size : 12 px ; font-family : "Cascadia Mono" , Consolas , monospace ; }
. status { display : inline-flex ; align-items : center ; gap : 7 px ; border-radius : 999 px ; padding : 5 px 9 px ; font-size : 12 px ; font-weight : 700 ; white-space : nowrap ; }
. status :: before { content : "" ; width : 7 px ; height : 7 px ; border-radius : 50 % ; background : currentColor ; }
. status . running { color : var ( -- success ); background : var ( -- success - soft ); }
. status . starting { color : var ( -- accent - strong ); background : var ( -- accent - soft ); }
2026-07-25 15:36:23 +08:00
. status . associated { color : var ( -- warning ); background : var ( -- warning - soft ); }
2026-07-22 14:51:11 +08:00
. status . occupied { color : var ( -- warning ); background : var ( -- warning - soft ); }
. status . exited { color : var ( -- muted ); background : var ( -- surface -3 ); }
2026-07-25 17:47:02 +08:00
. row-actions { display : flex ; justify-content : flex-end ; gap : 10 px ; opacity : .7 ; }
2026-07-22 14:51:11 +08:00
tr : hover . row-actions , tr . selected . row-actions { opacity : 1 ; }
2026-07-25 17:47:02 +08:00
. row-actions button { background : transparent ; color : var ( -- muted ); padding : 8 px 10 px ; border-radius : 6 px ; }
2026-07-22 14:51:11 +08:00
. row-actions button : hover { color : var ( -- accent - strong ); background : var ( -- accent - soft ); }
. table-footer { display : flex ; align-items : center ; justify-content : space-between ; padding : 12 px 16 px ; color : var ( -- muted ); font-size : 12 px ; }
. drawer-backdrop { position : fixed ; inset : 0 ; background : rgba ( 16 , 24 , 40 , .28 ); z-index : 10 ; opacity : 0 ; pointer-events : none ; transition : opacity .16 s ease ; }
. drawer-backdrop . open { opacity : 1 ; pointer-events : auto ; }
. drawer { position : fixed ; z-index : 11 ; top : 0 ; right : 0 ; bottom : 0 ; width : min ( 480 px , 100 vw ); background : var ( -- surface ); box-shadow : var ( -- shadow ); transform : translateX ( 100 % ); transition : transform .2 s ease ; overflow : auto ; }
. drawer . open { transform : translateX ( 0 ); }
. drawer-head { display : flex ; align-items : flex-start ; justify-content : space-between ; gap : 12 px ; padding : 28 px 26 px 20 px ; border-bottom : 1 px solid var ( -- line ); }
. drawer-head h2 { margin : 0 0 5 px ; font-size : 21 px ; letter-spacing : -.02 em ; }
. drawer-head p { margin : 0 ; color : var ( -- muted ); font-size : 13 px ; }
. drawer-body { padding : 22 px 26 px ; display : grid ; gap : 22 px ; }
. form-section { display : grid ; gap : 13 px ; }
. form-section h3 { margin : 0 ; font-size : 13 px ; color : var ( -- muted ); text-transform : uppercase ; letter-spacing : .06 em ; }
. field { display : grid ; gap : 6 px ; }
. field label { font-weight : 650 ; }
. field label span { color : var ( -- danger ); }
. field input , . field select { width : 100 % ; height : 38 px ; border : 1 px solid var ( -- line - strong ); background : var ( -- surface -2 ); color : var ( -- text ); border-radius : var ( -- radius - control ); padding : 0 11 px ; }
. field small , . help { color : var ( -- muted ); font-size : 12 px ; line-height : 1.45 ; }
. field . error input { border-color : var ( -- danger ); box-shadow : 0 0 0 2 px color-mix ( in srgb , var ( -- danger ) 18 % , transparent ); }
. field-error { color : var ( -- danger ); font-size : 12 px ; }
. check-line { display : flex ; gap : 9 px ; align-items : flex-start ; color : var ( -- text ); }
. check-line input { width : 16 px ; height : 16 px ; accent-color : var ( -- accent ); margin-top : 2 px ; }
. drawer-actions { display : flex ; justify-content : flex-end ; gap : 8 px ; border-top : 1 px solid var ( -- line ); padding-top : 18 px ; }
. detail-card { padding : 16 px ; background : var ( -- surface -2 ); border : 1 px solid var ( -- line ); border-radius : 12 px ; display : grid ; gap : 12 px ; }
. detail-line { display : flex ; justify-content : space-between ; gap : 18 px ; align-items : baseline ; }
. detail-line span : first-child { color : var ( -- muted ); }
. detail-line span : last-child { text-align : right ; max-width : 68 % ; overflow-wrap : anywhere ; }
. activity { display : grid ; gap : 9 px ; }
. 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 ; }
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 ); }
2026-07-25 16:21:38 +08:00
dialog . edit-dialog { width : min ( 620 px , calc ( 100 vw - 32 px )); }
2026-07-22 14:51:11 +08:00
dialog :: backdrop { background : rgba ( 16 , 24 , 40 , .42 ); }
. dialog-inner { padding : 24 px ; display : grid ; gap : 17 px ; }
. dialog-inner h2 { margin : 0 ; font-size : 19 px ; }
. 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 ; }
2026-07-25 18:07:57 +08:00
. dialog-list { margin : -3 px 0 0 ; padding-left : 20 px ; color : var ( -- text ); display : grid ; gap : 5 px ; }
. dialog-list li { line-height : 1.45 ; }
2026-07-22 14:51:11 +08:00
. dialog-actions { display : flex ; justify-content : flex-end ; gap : 8 px ; }
2026-07-25 16:21:38 +08:00
. 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 ; }
2026-07-22 14:51:11 +08:00
. 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 ; }
. 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 ; }
. toast { position : fixed ; right : 22 px ; bottom : 22 px ; z-index : 30 ; max-width : min ( 420 px , calc ( 100 vw - 44 px )); padding : 12 px 15 px ; border : 1 px solid var ( -- line ); border-left : 4 px solid var ( -- accent ); border-radius : 10 px ; background : var ( -- surface ); box-shadow : var ( -- shadow ); color : var ( -- text ); opacity : 0 ; transform : translateY ( 8 px ); pointer-events : none ; transition : opacity .16 s ease , transform .16 s ease ; }
. toast . show { opacity : 1 ; transform : translateY ( 0 ); }
. page-hidden { display : none !important ; }
2026-07-26 17:42:40 +08:00
. settings-workspace { padding : 16 px ; border : 1 px solid var ( -- line ); border-radius : calc ( var ( -- radius - card ) + 2 px ); background : var ( -- surface -2 ); }
. settings-grid { display : grid ; grid-template-columns : minmax ( 0 , 1.2 fr ) minmax ( 260 px , .8 fr ); gap : 16 px ; }
. settings-card { background : var ( -- surface ); border : 1 px solid var ( -- line - strong ); border-radius : var ( -- radius - card ); padding : 20 px ; display : grid ; gap : 16 px ; }
2026-07-22 14:51:11 +08:00
. settings-card h2 { margin : 0 ; font-size : 18 px ; letter-spacing : -.02 em ; }
2026-07-26 17:42:40 +08:00
. settings-card > p { margin : -10 px 0 0 ; color : var ( -- muted ); line-height : 1.5 ; }
. settings-card . form-section , . settings-card . setting-list { padding : 14 px ; border : 1 px solid var ( -- line ); border-radius : 10 px ; background : var ( -- surface -2 ); }
. settings-card . form-section { gap : 12 px ; }
. settings-card . field { padding : 12 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : var ( -- surface ); }
. settings-card . field : focus-within { border-color : var ( -- accent ); box-shadow : var ( -- focus ); }
. settings-card . check-line { padding : 12 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : var ( -- surface ); }
2026-07-22 15:57:02 +08:00
. path-field { display : grid ; grid-template-columns : minmax ( 0 , 1 fr ) auto auto ; gap : 8 px ; }
. radio-row { display : flex ; flex-wrap : wrap ; gap : 16 px ; align-items : center ; min-height : 38 px ; }
. radio-row label { font-weight : 500 ; display : inline-flex ; gap : 7 px ; align-items : center ; }
2026-07-26 17:42:40 +08:00
. settings-status { min-height : 42 px ; padding : 11 px 12 px ; border : 1 px solid color-mix ( in srgb , var ( -- success ) 35 % , var ( -- line )); border-radius : 9 px ; background : var ( -- success - soft ); color : var ( -- success ); font-size : 12 px ; line-height : 1.45 ; }
2026-07-22 14:51:11 +08:00
. settings-status . warning { background : var ( -- warning - soft ); color : var ( -- warning ); }
2026-07-26 17:42:40 +08:00
. settings-actions { display : flex ; justify-content : flex-end ; gap : 8 px ; border : 1 px solid var ( -- line ); border-radius : 10 px ; background : var ( -- surface -2 ); padding : 12 px ; }
2026-07-22 14:51:11 +08:00
. setting-list { display : grid ; gap : 12 px ; }
. setting-list . detail-line { padding-bottom : 12 px ; border-bottom : 1 px solid var ( -- line ); }
. setting-list . detail-line : last-child { border-bottom : 0 ; padding-bottom : 0 ; }
@ media ( max-width : 800px ) { . settings-grid { grid-template-columns : 1 fr ; } }
@ media ( max-width : 1007px ) {
. app { grid-template-columns : 70 px minmax ( 0 , 1 fr ); }
. sidebar { padding-inline : 10 px ; }
. brand { justify-content : center ; padding : 0 ; }
. brand > div : last-child , . nav button span : not ( . nav-icon ), . sidebar-footer { display : none ; }
. nav button { justify-content : center ; padding : 0 ; }
. summary-grid { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
}
@ media ( max-width : 640px ) {
. app { display : block ; }
. sidebar { height : 58 px ; padding : 10 px 14 px ; border-right : 0 ; border-bottom : 1 px solid var ( -- line ); flex-direction : row ; align-items : center ; }
. brand { margin-right : auto ; }
. brand > div : last-child { display : block ; }
. nav { display : flex ; }
. nav button { width : 38 px ; }
. nav button span : not ( . nav-icon ) { display : none ; }
. main { padding : 28 px 12 px ; }
. topbar { display : block ; }
2026-07-25 16:21:38 +08:00
. top-actions { margin-top : 16 px ; width : 100 % ; }
. instance-commands { grid-template-columns : 1 fr ; }
2026-07-22 14:51:11 +08:00
. summary-grid { gap : 8 px ; }
. summary { padding : 12 px ; }
. summary-value { font-size : 21 px ; }
. toolbar { align-items : stretch ; }
. search { min-width : 100 % ; }
. toolbar-spacer { display : none ; }
. toolbar select { flex : 1 ; }
}
@ media ( prefers-reduced-motion : reduce ) { *, * :: before , * :: after { transition-duration : .01 ms !important ; animation-duration : .01 ms !important ; } }
</ style >
</ head >
< body >
2026-07-25 18:07:57 +08:00
< div class = "prototype-ribbon" > PROTOTYPE · 仅供交互确认,非生产实现 · Ctrl+Shift+E 模拟受管浏览器退出</ div >
2026-07-22 14:51:11 +08:00
< div class = "app" id = "app" >
< aside class = "sidebar" aria-label = "主导航" >
< div class = "brand" >< div class = "brand-mark" aria-hidden = "true" > C</ div >< div >< strong > Chub</ strong >< span > Browser Hub</ span ></ div ></ div >
< nav class = "nav" >
< button aria-current = "page" data-nav = "instances" >< span class = "nav-icon" aria-hidden = "true" > ▦</ span >< span > 实例</ span ></ button >
< button data-nav = "settings" >< span class = "nav-icon" aria-hidden = "true" > ⚙</ span >< span > 设置</ span ></ button >
</ nav >
< div class = "sidebar-footer" > 本地运行< br > 不保存浏览器凭证</ div >
</ aside >
< main class = "main" >
< 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 >
2026-07-25 16:21:38 +08:00
< 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 >
2026-07-22 14:51:11 +08:00
</ header >
< section class = "content" id = "instancesPage" aria-label = "实例工作区" >
< div class = "banner" role = "status" >< div aria-hidden = "true" > ●</ div >< div >< b > 本地安全模式</ b >< p > Chub 只管理已注册且身份校验通过的实例。外部浏览器不会被自动关闭。</ p ></ div ></ div >
< section class = "summary-grid" aria-label = "实例概览" >
< article class = "summary" >< div class = "summary-label" > 全部实例</ div >< div class = "summary-value" > 4 < span class = "summary-note" > 已配置</ span ></ div ></ article >
< article class = "summary" >< div class = "summary-label" > 运行中</ div >< div class = "summary-value" style = "color:var(--success)" > 1 < span class = "summary-note" > 稳定</ span ></ div ></ article >
< article class = "summary" >< div class = "summary-label" > 启动中</ div >< div class = "summary-value" style = "color:var(--accent-strong)" > 1 < span class = "summary-note" > 请稍候</ span ></ div ></ article >
< article class = "summary" >< div class = "summary-label" > 需处理</ div >< div class = "summary-value" style = "color:var(--warning)" > 1 < span class = "summary-note" > profile 占用</ span ></ div ></ article >
</ section >
< section class = "workspace" aria-labelledby = "listTitle" >
2026-07-25 16:21:38 +08:00
< 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 >
2026-07-25 15:36:23 +08:00
< 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 >
2026-07-22 14:51:11 +08:00
< div class = "table-footer" >< span id = "resultCount" > 显示 4 个实例</ span >< span > 最后检查:刚刚</ span ></ div >
</ section >
</ section >
< section class = "content page-hidden" id = "settingsPage" aria-label = "应用设置" >
2026-07-26 17:42:40 +08:00
< div class = "settings-workspace" >< div class = "settings-grid" >
2026-07-22 14:51:11 +08:00
< form class = "settings-card" id = "settingsForm" >
< div >< h2 > 浏览器与目录</ h2 >< p > 这些设置用于新建实例的默认值;已有实例的配置不会被静默修改。</ p ></ div >
< div class = "form-section" >< h3 > 浏览器路径</ h3 >
2026-07-22 15:57:02 +08:00
< div class = "field" >< label for = "chromePath" > Chrome 可执行文件</ label >< div class = "path-field" >< input id = "chromePath" value = "自动发现:chrome.exe" >< button type = "button" class = "secondary browse-setting" data-target = "chromePath" > 选择文件</ button >< button type = "button" class = "secondary search-setting" data-target = "chromePath" > 搜索</ button ></ div >< small > 留空或选择文件后,Chub 启动时会校验路径可访问。</ small ></ div >
< div class = "field" >< label for = "edgePath" > Edge 可执行文件</ label >< div class = "path-field" >< input id = "edgePath" value = "自动发现:msedge.exe" >< button type = "button" class = "secondary browse-setting" data-target = "edgePath" > 选择文件</ button >< button type = "button" class = "secondary search-setting" data-target = "edgePath" > 搜索</ button ></ div >< small > Chrome 与 Edge 可分别设置,也可以继续使用自动发现。</ small ></ div >
2026-07-22 14:51:11 +08:00
</ div >
< div class = "form-section" >< h3 > 默认目录</ h3 >
2026-07-22 15:57:02 +08:00
< 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 >
2026-07-22 14:51:11 +08:00
</ div >
2026-07-25 17:47:02 +08:00
< div class = "form-section" >< h3 > 代理</ h3 >< p class = "help" > 仅保存无认证代理。下拉框、实例创建和编辑均展示完整地址。</ p >< div class = "field" >< label for = "proxySelect" > 已保存代理</ label >< select id = "proxySelect" >< option value = "" > 选择代理(新建)</ option ></ select >< small > 选择后会回填地址;选择“新建”会清空输入框。</ small ></ div >< div class = "field" >< label for = "proxyServer" > 代理地址</ label >< div class = "path-field" >< input id = "proxyServer" placeholder = "例如:http://127.0.0.1:8080" >< button type = "button" class = "primary" id = "proxySave" > 保存代理</ button >< button type = "button" class = "danger-button" id = "proxyDelete" disabled > 删除代理</ button ></ div >< small > 支持 http、https、socks4、socks5 的 < span class = "mono" > scheme://host:port</ span > ;不允许用户名、密码或路径。</ small ></ div >< div class = "settings-status" id = "proxyStatus" > 新建代理:填写地址后保存。</ div ></ div >
2026-07-22 14:51:11 +08:00
< div class = "settings-actions" >< button type = "button" class = "secondary" id = "discardSettings" > 取消</ button >< button type = "submit" class = "primary" > 保存设置</ button ></ div >
</ form >
2026-07-25 15:36:23 +08:00
< 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 >
2026-07-26 17:42:40 +08:00
</ div ></ div >
2026-07-22 14:51:11 +08:00
</ section >
</ main >
</ div >
< 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 >
2026-07-27 09:29:10 +08:00
< 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 = "editPreferredPort" > 首选调试端口</ label >< input id = "editPreferredPort" type = "number" min = "1024" max = "65535" inputmode = "numeric" >< small > 下次启动优先使用;与其他 Chub 实例重复时会提示修改。</ 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 >
2026-07-25 16:21:38 +08:00
< 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 >
2026-07-25 17:47:02 +08:00
< dialog id = "proxyDeleteDialog" aria-labelledby = "proxyDeleteTitle" >< div class = "dialog-inner" >< h2 id = "proxyDeleteTitle" > 删除代理?</ h2 >< p id = "proxyDeleteText" ></ p >< div class = "dialog-actions" >< button class = "secondary" id = "proxyDeleteCancel" > 取消</ button >< button class = "danger-button" id = "proxyDeleteConfirm" > 删除代理</ button ></ div ></ div ></ dialog >
2026-07-25 18:07:57 +08:00
< dialog id = "unexpectedExitDialog" aria-labelledby = "unexpectedExitTitle" aria-describedby = "unexpectedExitText" >< div class = "dialog-inner" >< h2 id = "unexpectedExitTitle" > 浏览器已退出</ h2 >< p id = "unexpectedExitText" ></ p >< ul class = "dialog-list" id = "unexpectedExitList" ></ ul >< div class = "form-note" > 实例配置和 User Data Dir 未被删除。关闭此提示后,可以从实例列表重新启动浏览器。</ div >< div class = "dialog-actions" >< button class = "primary" id = "unexpectedExitAcknowledge" > 知道了</ button ></ div ></ div ></ dialog >
2026-07-22 14:51:11 +08:00
< div class = "toast" id = "toast" role = "status" aria-live = "polite" ></ div >
< script >
2026-07-25 16:53:44 +08:00
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' }
];
2026-07-22 14:51:11 +08:00
const instances = [
2026-07-27 09:29:10 +08:00
{ id : 'inst-chrome-shop' , name : 'Shopee · SG 主店' , kind : 'chrome' , browser : 'Chrome' , status : 'running' , statusText : '运行中(托管)' , pid : '18420' , port : 9666 , preferredPort : 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 : 0 , preferredPort : 9667 , 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 , preferredPort : 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 : 0 , preferredPort : 9669 , 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' }
2026-07-22 14:51:11 +08:00
];
let selectedId = 'inst-chrome-shop' ;
let modalAction = 'stop' ;
2026-07-25 16:21:38 +08:00
let editingID = '' ;
let editSnapshot = null ;
2026-07-25 17:47:02 +08:00
let selectedProxyID = '' ;
2026-07-25 18:07:57 +08:00
let unexpectedExitIDs = [];
let unexpectedExitFocusID = '' ;
2026-07-22 14:51:11 +08:00
const $ = ( id ) => document . getElementById ( id );
const statusClass = ( s ) => s ;
2026-07-25 17:47:02 +08:00
const proxyLabel = ( id ) => proxies . find ( p => p . id === id ) ? . server || '无代理' ;
2026-07-27 09:29:10 +08:00
const portLabel = ( x ) => x . status === 'associated' ? ( x . port ? `外部 ${ x . port } ` : '—' ) : x . status === 'starting' ? ( x . preferredPort ? `准备 ${ x . preferredPort } ` : '—' ) : x . port ? `实际 ${ x . port } ` : x . status === 'exited' ? ( x . preferredPort ? `建议 ${ x . preferredPort } ` : '—' ) : '—' ;
2026-07-25 17:47:02 +08:00
function renderProxyOptions ( select , selected = '' ) { select . innerHTML = '<option value="">无代理</option>' + proxies . map ( p => `<option value=" ${ p . id } "> ${ p . server } </option>` ). join ( '' ); select . value = selected ; }
function renderProxies () { const select = $ ( 'proxySelect' ), input = $ ( 'proxyServer' ), status = $ ( 'proxyStatus' ), remove = $ ( 'proxyDelete' ); select . innerHTML = '<option value="">选择代理(新建)</option>' + proxies . map ( p => `<option value=" ${ p . id } "> ${ p . server } </option>` ). join ( '' ); if ( ! proxies . some ( p => p . id === selectedProxyID )) selectedProxyID = '' ; select . value = selectedProxyID ; const selected = proxies . find ( p => p . id === selectedProxyID ); input . value = selected ? . server || '' ; remove . disabled = ! selected ; status . textContent = selected ? `正在编辑已保存代理;已被 ${ instances . filter ( i => i . proxyId === selected . id ). length } 个实例使用。` : '新建代理:填写地址后保存。' ; }
2026-07-22 14:51:11 +08:00
function filtered () {
const q = $ ( 'search' ). value . trim (). toLowerCase (), browser = $ ( 'browserFilter' ). value , status = $ ( 'statusFilter' ). value ;
2026-07-27 09:29:10 +08:00
return instances . filter ( x => ( ! q || [ x . name , x . browser , x . pid , portLabel ( x ), x . dir ]. join ( ' ' ). toLowerCase (). includes ( q )) && ( browser === 'all' || x . kind === browser ) && ( status === 'all' || x . status === status ));
2026-07-22 14:51:11 +08:00
}
2026-07-25 16:21:38 +08:00
function selectRow ( id , body ) {
selectedId = id ;
body . querySelectorAll ( 'tr' ). forEach ( row => row . classList . toggle ( 'selected' , row . dataset . id === id ));
}
2026-07-22 14:51:11 +08:00
function renderRows () {
const rows = filtered (), body = $ ( 'instanceRows' ); body . innerHTML = '' ;
2026-07-25 16:21:38 +08:00
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 = `
2026-07-22 16:22:59 +08:00
<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>
2026-07-27 09:29:10 +08:00
<td> ${ x . browser } </td><td class="mono"> ${ x . dir } </td><td class="mono"> ${ portLabel ( x ) } </td><td><span class="status ${ statusClass ( x . status ) } "> ${ x . statusText } </span></td>
2026-07-25 17:47:02 +08:00
<td><div class="row-actions"><button data-action="start" aria-label=" ${ x . status === 'associated' ? '重新检测' : x . status === 'running' ? '停止' : '启动' } ${ x . name } "> ${ x . status === 'associated' ? '重新检测' : x . status === 'running' ? '■ 停止' : '▶ 启动' } </button><button data-action="edit" aria-label="编辑 ${ x . name } ">编辑</button><button data-action="delete" aria-label="删除 ${ x . name } ">删除</button></div></td>` ;
2026-07-25 16:21:38 +08:00
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 ); } });
2026-07-27 09:29:10 +08:00
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 = 0 ; renderRows (); showToast ( `“ ${ x . name } ”已优雅退出。` ); }, 700 ); } else openConfirm ( x . id , 'start' ); }));
2026-07-22 14:51:11 +08:00
body . appendChild ( tr );
});
$ ( 'emptyState' ). style . display = rows . length ? 'none' : 'block' ; $ ( 'resultCount' ). textContent = `显示 ${ rows . length } 个实例` ;
}
2026-07-27 09:29:10 +08:00
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"> ${ portLabel ( x ) } </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' ); }
2026-07-25 16:21:38 +08:00
function editIsLocked ( x ) { return [ 'running' , 'starting' , 'associated' ]. includes ( x . status ); }
function editIsDirty () {
if ( ! editingID || ! editSnapshot ) return false ;
2026-07-27 09:29:10 +08:00
return $ ( 'editName' ). value !== editSnapshot . name || $ ( 'editDataDir' ). value !== editSnapshot . dir || $ ( 'editURL' ). value !== editSnapshot . url || $ ( 'editPreferredPort' ). value !== String ( editSnapshot . preferredPort ) || $ ( 'editProxy' ). value !== editSnapshot . proxyId || document . querySelector ( 'input[name="editKind"]:checked' ) ? . value !== editSnapshot . kind ;
2026-07-25 16:21:38 +08:00
}
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 ;
2026-07-27 09:29:10 +08:00
selectedId = id ; editingID = id ; editSnapshot = { name : x . name , dir : x . dir , url : x . url || '' , kind : x . kind , preferredPort : x . preferredPort || '' , proxyId : x . proxyId || '' };
const locked = editIsLocked ( x ); $ ( 'editName' ). value = x . name ; $ ( 'editDataDir' ). value = x . dir ; $ ( 'editURL' ). value = x . url || '' ; $ ( 'editPreferredPort' ). value = x . preferredPort || '' ; renderProxyOptions ( $ ( 'editProxy' ), x . proxyId || '' ); $ ( 'editPort' ). value = x . port || '—' ; $ ( 'editChrome' ). checked = x . kind === 'chrome' ; $ ( 'editEdge' ). checked = x . kind === 'edge' ;
2026-07-25 16:21:38 +08:00
$ ( 'editSubtitle' ). textContent = locked ? '当前运行状态下可查看配置,但身份约束字段不可修改。' : '修改下次启动使用的保存配置。' ;
2026-07-27 09:29:10 +08:00
$ ( 'editChrome' ). disabled = locked ; $ ( 'editEdge' ). disabled = locked ; $ ( 'editDataDir' ). disabled = locked ; $ ( 'chooseEditDir' ). disabled = locked ; $ ( 'editPreferredPort' ). disabled = locked ; $ ( 'editReadOnlyNote' ). hidden = ! locked ;
2026-07-25 16:21:38 +08:00
$ ( 'editDialog' ). showModal (); setTimeout (() => $ ( 'editName' ). focus (), 0 );
}
function saveEdit () {
const x = instances . find ( i => i . id === editingID ); if ( ! x ) return finishEdit ();
2026-07-27 09:29:10 +08:00
const name = $ ( 'editName' ). value . trim (), dir = $ ( 'editDataDir' ). value . trim (), url = $ ( 'editURL' ). value . trim (), preferredPort = Number ( $ ( 'editPreferredPort' ). value );
2026-07-25 16:21:38 +08:00
if ( ! name || ! dir ) { showToast ( '请填写实例名称和 User Data Dir。' ); ( ! name ? $ ( 'editName' ) : $ ( 'editDataDir' )). focus (); return ; }
2026-07-25 16:53:44 +08:00
const locked = editIsLocked ( x ); x . name = name ; x . url = url ; x . proxyId = $ ( 'editProxy' ). value ;
2026-07-27 09:29:10 +08:00
if ( ! locked ) { if ( ! Number . isInteger ( preferredPort ) || preferredPort < 1024 || preferredPort > 65535 ) { showToast ( '请填写有效的首选调试端口。' ); $ ( 'editPreferredPort' ). focus (); return ; } x . dir = dir ; x . kind = document . querySelector ( 'input[name="editKind"]:checked' ) ? . value || x . kind ; x . browser = x . kind === 'edge' ? 'Edge' : 'Chrome' ; x . preferredPort = preferredPort ; }
2026-07-25 16:21:38 +08:00
if ( $ ( 'discardDialog' ). open ) $ ( 'discardDialog' ). close (); renderRows (); const savedName = x . name ; finishEdit (); showToast ( `已保存“ ${ savedName } ”的实例配置。` );
}
function requestEditClose () { if ( editIsDirty ()) { $ ( 'editDialog' ). close (); $ ( 'discardDialog' ). showModal (); return ; } finishEdit (); }
2026-07-27 09:29:10 +08:00
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="launchPreferredPort">首选调试端口</label><input id="launchPreferredPort" type="number" min="1024" max="65535" value="9670" inputmode="numeric"><small>系统从此值开始建议未被其他 Chub 实例预留的端口;实际端口会在启动时验证。</small></div><div class="field"><label for="launchProxy">代理</label><select 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' ), port = Number ( $ ( 'launchPreferredPort' ). value ); 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 ; } if ( ! Number . isInteger ( port ) || port < 1024 || port > 65535 ) { $ ( 'launchPreferredPort' ). focus (); showToast ( '请填写有效的首选调试端口。' ); return ; } const proxy = proxyLabel ( $ ( 'launchProxy' ). value ); hideDrawer (); setTimeout (() => showToast ( `启动请求已提交(首选端口 ${ port } ; ${ proxy } ),实例进入 STARTING 状态。` ), 80 ); }; }
2026-07-22 14:51:11 +08:00
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 (); }
function showToast ( message ) { const toast = $ ( 'toast' ); toast . textContent = message ; toast . classList . add ( 'show' ); clearTimeout ( showToast . timer ); showToast . timer = setTimeout (() => toast . classList . remove ( 'show' ), 3600 ); }
2026-07-25 18:07:57 +08:00
function hasOtherModal () { return [... document . querySelectorAll ( 'dialog[open]' )]. some ( dialog => dialog . id !== 'unexpectedExitDialog' ); }
function queueUnexpectedExit ( id ) {
const instance = instances . find ( x => x . id === id );
if ( ! instance || instance . status !== 'running' ) return ;
2026-07-27 09:29:10 +08:00
instance . status = 'exited' ; instance . statusText = '已退出' ; instance . pid = '—' ; instance . port = 0 ; instance . exit = '已检测到进程退出' ;
2026-07-25 18:07:57 +08:00
if ( ! unexpectedExitIDs . includes ( id )) unexpectedExitIDs . push ( id );
selectedId = id ;
renderRows ();
presentUnexpectedExitNotice ();
}
function presentUnexpectedExitNotice () {
const dialog = $ ( 'unexpectedExitDialog' );
const exited = unexpectedExitIDs . map ( id => instances . find ( x => x . id === id )). filter ( Boolean );
if ( ! exited . length || hasOtherModal ()) return ;
$ ( 'unexpectedExitTitle' ). textContent = exited . length === 1 ? '浏览器已退出' : ` ${ exited . length } 个浏览器已退出` ;
$ ( 'unexpectedExitText' ). textContent = exited . length === 1 ? `检测到“ ${ exited [ 0 ]. name } ”的 ${ exited [ 0 ]. browser } 已退出。` : '检测到以下由 Chub 启动的浏览器已退出。' ;
$ ( 'unexpectedExitList' ). innerHTML = exited . map ( x => `<li><strong> ${ x . name } </strong> · ${ x . browser } </li>` ). join ( '' );
unexpectedExitFocusID = exited [ 0 ]. id ;
if ( ! dialog . open ) { dialog . showModal (); setTimeout (() => $ ( 'unexpectedExitAcknowledge' ). focus (), 0 ); }
}
function dismissUnexpectedExitNotice () {
const focusID = unexpectedExitFocusID ;
unexpectedExitIDs = [];
$ ( 'unexpectedExitDialog' ). close ();
requestAnimationFrame (() => {
const start = document . querySelector ( `tr[data-id=" ${ focusID } "] button[data-action="start"]` );
( start || document . querySelector ( `tr[data-id=" ${ focusID } "]` )) ? . focus ();
});
}
2026-07-22 14:51:11 +08:00
$ ( '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 ); };
2026-07-25 18:07:57 +08:00
$ ( 'unexpectedExitAcknowledge' ). onclick = dismissUnexpectedExitNotice ; $ ( 'unexpectedExitDialog' ). addEventListener ( 'cancel' , e => { e . preventDefault (); dismissUnexpectedExitNotice (); }); document . querySelectorAll ( 'dialog' ). forEach ( dialog => dialog . addEventListener ( 'close' , () => setTimeout ( presentUnexpectedExitNotice , 0 )));
2026-07-25 16:21:38 +08:00
function refreshInstances () { const button = $ ( 'refresh' ); if ( button . disabled ) return ; button . disabled = true ; button . textContent = '↻ 刷新中…' ; setTimeout (() => { const starting = instances . find ( x => x . status === 'starting' ); 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 ); }
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' ; $ ( 'pageTitle' ). textContent = settings ? '设置' : '浏览器实例' ; $ ( 'pageSubtitle' ). 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 ;
2026-07-25 18:07:57 +08:00
document . addEventListener ( 'keydown' , e => { if ( e . ctrlKey && e . shiftKey && e . key . toLowerCase () === 'e' ) { e . preventDefault (); queueUnexpectedExit ( 'inst-chrome-shop' ); return ; } if ( e . key === 'F5' && ! $ ( 'editDialog' ). open && ! $ ( 'discardDialog' ). open ) { e . preventDefault (); refreshInstances (); } });
2026-07-22 14:51:11 +08:00
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' ) ? '文件' : '目录' } 选择器。` ));
2026-07-22 15:57:02 +08:00
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 )); });
2026-07-25 17:47:02 +08:00
$ ( 'proxySelect' ). onchange = () => { selectedProxyID = $ ( 'proxySelect' ). value ; renderProxies (); $ ( 'proxyServer' ). focus (); };
$ ( 'proxySave' ). onclick = () => { const server = $ ( 'proxyServer' ). value . trim (). toLowerCase (); if ( ! /^(https?|socks[45]):\/\/[^\/@?#:]+(?::\d+)$/ . test ( server )) { $ ( 'proxyStatus' ). textContent = '请填写无认证的 scheme://host:port 代理地址。' ; return ; } if ( proxies . some ( p => p . server === server && p . id !== selectedProxyID )) { $ ( 'proxyStatus' ). textContent = '该代理地址已存在,请从下拉框选择它。' ; return ; } if ( selectedProxyID ) { const p = proxies . find ( item => item . id === selectedProxyID ); p . server = server ; p . name = server ; } else { selectedProxyID = `proxy- ${ Date . now () } ` ; proxies . push ({ id : selectedProxyID , name : server , server }); } renderProxies (); showToast ( '代理已保存;后续启动将使用最新端点。' ); };
$ ( 'proxyDelete' ). onclick = () => { const proxy = proxies . find ( p => p . id === selectedProxyID ), uses = instances . filter ( i => i . proxyId === selectedProxyID ). length ; if ( ! proxy ) return ; if ( uses ) { $ ( 'proxyStatus' ). textContent = `该代理仍被 ${ uses } 个实例使用,不能删除。` ; return ; } $ ( 'proxyDeleteText' ). textContent = `确定删除代理“ ${ proxy . server } ”?此操作不会修改任何实例。` ; $ ( 'proxyDeleteDialog' ). showModal (); setTimeout (() => $ ( 'proxyDeleteCancel' ). focus (), 0 ); };
$ ( 'proxyDeleteCancel' ). onclick = () => $ ( 'proxyDeleteDialog' ). close (); $ ( 'proxyDeleteDialog' ). addEventListener ( 'cancel' , e => { e . preventDefault (); $ ( 'proxyDeleteDialog' ). close (); }); $ ( 'proxyDeleteConfirm' ). onclick = () => { const proxy = proxies . find ( p => p . id === selectedProxyID ); if ( proxy ) proxies . splice ( proxies . indexOf ( proxy ), 1 ); selectedProxyID = '' ; $ ( 'proxyDeleteDialog' ). close (); renderProxies (); $ ( 'proxySelect' ). focus (); showToast ( '代理已删除。' ); };
2026-07-22 14:51:11 +08:00
$ ( 'settingsForm' ). addEventListener ( 'submit' , e => { e . preventDefault (); $ ( 'settingsStatus' ). textContent = '设置已保存 · 最后更新:刚刚' ; $ ( 'settingsStatus' ). classList . remove ( 'warning' ); showToast ( '设置已保存,后续新建实例将使用新的默认值。' ); });
$ ( 'discardSettings' ). onclick = () => { $ ( 'settingsForm' ). reset (); $ ( 'settingsStatus' ). textContent = '已取消本次修改,恢复到上次保存值。' ; $ ( 'settingsStatus' ). classList . add ( 'warning' ); showToast ( '已取消设置修改。' ); };
2026-07-25 16:53:44 +08:00
renderRows (); renderProxies ();
2026-07-22 14:51:11 +08:00
</ script >
</ body >
</ html >