@@ -0,0 +1,312 @@
<!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 ) ; }
. top-actions { display : flex ; gap : 8 px ; align-items : center ; }
. 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 ) ; }
. status . occupied { color : var ( - - warning ) ; background : var ( - - warning - soft ) ; }
. status . exited { color : var ( - - muted ) ; background : var ( - - surface -3 ) ; }
. row-actions { display : flex ; justify-content : flex-end ; gap : 6 px ; opacity : .7 ; }
tr : hover . row-actions , tr . selected . row-actions { opacity : 1 ; }
. row-actions button { background : transparent ; color : var ( - - muted ) ; padding : 6 px 8 px ; border-radius : 6 px ; }
. 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 ) ; }
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 ; }
. dialog-actions { display : flex ; justify-content : flex-end ; gap : 8 px ; }
. 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 ; }
. settings-grid { display : grid ; grid-template-columns : minmax ( 0 , 1.2 fr ) minmax ( 260 px , .8 fr ) ; gap : 18 px ; }
. settings-card { background : var ( - - surface ) ; border : 1 px solid var ( - - line ) ; border-radius : var ( - - radius - card ) ; padding : 22 px ; display : grid ; gap : 20 px ; }
. settings-card h2 { margin : 0 ; font-size : 18 px ; letter-spacing : -.02 em ; }
. settings-card > p { margin : -12 px 0 0 ; color : var ( - - muted ) ; line-height : 1.5 ; }
. path-field { display : grid ; grid-template-columns : minmax ( 0 , 1 fr ) auto ; gap : 8 px ; }
. settings-status { min-height : 42 px ; padding : 11 px 12 px ; border-radius : 9 px ; background : var ( - - success - soft ) ; color : var ( - - success ) ; font-size : 12 px ; line-height : 1.45 ; }
. settings-status . warning { background : var ( - - warning - soft ) ; color : var ( - - warning ) ; }
. settings-actions { display : flex ; justify-content : flex-end ; gap : 8 px ; border-top : 1 px solid var ( - - line ) ; padding-top : 18 px ; }
. 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 ; }
. top-actions { margin-top : 16 px ; }
. top-actions . primary { flex : 1 ; }
. 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 >
< div class = "prototype-ribbon" > PROTOTYPE · 仅供交互确认,非生产实现< / div >
< 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 >
< div class = "top-actions" > < button class = "icon-button" id = "themeToggle" aria-label = "切换深色主题" title = "切换深色主题" > ☾< / button > < button class = "primary" id = "newInstance" > + 新建实例< / button > < / div >
< / 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" >
< 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 = "occupied" > 被占用< / option > < option value = "exited" > 已退出< / option > < / select > < div class = "toolbar-spacer" > < / div > < button class = "secondary" id = "refresh" > ↻ 刷新状态< / button > < / 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" > PID< / th > < th scope = "col" > user data dir< / th > < th scope = "col" > 最近启动< / th > < th scope = "col" > < span class = "sr-only" > 操作< / span > < / 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 >
< / section >
< / section >
< section class = "content page-hidden" id = "settingsPage" aria-label = "应用设置" >
< div class = "settings-grid" >
< form class = "settings-card" id = "settingsForm" >
< div > < h2 > 浏览器与目录< / h2 > < p > 这些设置用于新建实例的默认值;已有实例的配置不会被静默修改。< / p > < / div >
< div class = "form-section" > < h3 > 浏览器路径< / h3 >
< 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 > < / 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 > < / div > < small > Chrome 与 Edge 可分别设置,也可以继续使用自动发现。< / small > < / div >
< / div >
< div class = "form-section" > < h3 > 默认目录< / h3 >
< 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 > < / 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 > < / div > < small > 日志仅保存必要诊断信息,并过滤敏感参数。< / small > < / 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 > < label class = "check-line" > < input type = "checkbox" id = "enableCdp" > < span > < b > 允许本地 CDP< / b > < br > < small class = "help" > 仅绑定 loopback; MVP 默认关闭。< / small > < / span > < / label > < div class = "settings-status" id = "settingsStatus" > 设置已保存 · 最后更新:刚刚< / div > < / div > < / aside >
< / div >
< / 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 >
< div class = "toast" id = "toast" role = "status" aria-live = "polite" > < / div >
< script >
const instances = [
{ id : 'inst-chrome-shop' , name : 'Shopee · SG 主店' , kind : 'chrome' , browser : 'Chrome' , status : 'running' , statusText : '运行中' , pid : '18420' , 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-edge-review' , name : '素材审核 · Edge' , kind : 'edge' , browser : 'Edge' , status : 'starting' , statusText : '启动中' , pid : '—' , 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 : 'occupied' , statusText : '被占用' , pid : '16108' , 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 : '—' , 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' }
] ;
let selectedId = 'inst-chrome-shop' ;
let modalAction = 'stop' ;
const $ = ( id ) => document . getElementById ( id ) ;
const statusClass = ( s ) => s ;
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 . dir ] . join ( ' ' ) . toLowerCase ( ) . includes ( q ) ) && ( browser === 'all' || x . kind === browser ) && ( status === 'all' || x . status === status ) ) ;
}
function renderRows ( ) {
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 = `
<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 . browser } · ${ x . id } </small></div></div></td>
<td><span class="status ${ statusClass ( x . status ) } "> ${ x . statusText } </span></td><td class="mono"> ${ x . pid } </td><td class="mono"> ${ x . dir } </td><td> ${ x . started } </td>
<td><div class="row-actions"><button data-action="details" aria-label="查看 ${ x . name } 详情">详情</button> ${ x . status === 'running' || x . status === 'occupied' ? ` <button data-action="stop" aria-label="关闭 ${ x . name } ">关闭</button> ` : ` <button data-action="start" aria-label="启动 ${ x . name } ">启动</button> ` } </div></td> ` ;
tr . addEventListener ( 'click' , e => { if ( ! e . target . closest ( 'button' ) ) openDetails ( x . id ) ; } ) ;
tr . addEventListener ( 'keydown' , e => { if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault ( ) ; openDetails ( 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' ) ; } ) ) ;
body . appendChild ( tr ) ;
} ) ;
$ ( 'emptyState' ) . style . display = rows . length ? 'none' : 'block' ; $ ( 'resultCount' ) . textContent = ` 显示 ${ rows . length } 个实例 ` ;
}
function openDetails ( id ) { selectedId = id ; const x = instances . find ( i => i . id === id ) ; $ ( '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> ${ 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' ? 'disabled' : '' } >↻ 重启</button><button class="danger-button" id="stopBtn" ${ x . status === 'exited' ? 'disabled' : '' } >关闭实例</button></div></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> ${ 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 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"><label for="kind">浏览器 <span>*</span></label><select id="kind"><option>Chrome</option><option>Edge</option></select></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><input id="dataDir" placeholder="例如:D: \\ Chub \\ profiles \\ new-instance" required><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="profile">profile-directory</label><input id="profile" value="Default"></div><div class="field"><label for="url">启动地址 <span>*</span></label><input id="url" type="url" value="https://" required></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 ; $ ( '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 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 ) ; }
$ ( '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 settings' : 'Browser instances' ; $ ( 'pageTitle' ) . textContent = settings ? '设置' : '浏览器实例' ; $ ( 'pageSubtitle' ) . textContent = settings ? '管理浏览器路径、默认目录和本地运行行为' : '启动、观察并安全管理 Chrome 与 Edge 环境' ; $ ( 'newInstance' ) . 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 = ( ) => { $ ( 'refresh' ) . textContent = '✓ 已刷新' ; setTimeout ( ( ) => $ ( 'refresh' ) . textContent = '↻ 刷新状态' , 1200 ) ; } ; $ ( 'themeToggle' ) . onclick = ( ) => { const dark = document . documentElement . dataset . theme === 'dark' ; document . documentElement . dataset . theme = dark ? '' : 'dark' ; $ ( 'themeToggle' ) . textContent = dark ? '☾' : '☀' ; $ ( 'themeToggle' ) . setAttribute ( 'aria-label' , dark ? '切换深色主题' : '切换浅色主题' ) ; } ;
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' ) ? '文件' : '目录' } 选择器。 ` ) ) ;
$ ( '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 ( ) ;
< / script >
< / body >
< / html >