Files
cdp_hub/docs/ui/browser-manager.html
T

371 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 18px 48px rgba(26, 43, 73, .14), 0 2px 7px rgba(26, 43, 73, .08);
--radius-control: 8px;
--radius-card: 14px;
--radius-dialog: 16px;
--focus: 0 0 0 3px 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 18px 48px 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: 14px; }
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: 5px 14px; border-radius: 0 0 8px 8px; font-size: 11px; letter-spacing: .04em; }
.app { display: grid; grid-template-columns: 232px minmax(0, 1fr); min-height: 100vh; }
.sidebar { background: var(--surface); border-right: 1px solid var(--line); padding: 34px 14px 18px; display: flex; flex-direction: column; gap: 24px; }
.brand { display: flex; align-items: center; gap: 10px; padding: 0 10px; }
.brand-mark { width: 32px; height: 32px; border-radius: 10px; background: linear-gradient(135deg, #2563eb, #66a1ff); color: #fff; display: grid; place-items: center; font-weight: 800; letter-spacing: -.04em; }
.brand strong { display: block; font-size: 17px; letter-spacing: -.02em; }
.brand span { color: var(--muted); font-size: 11px; }
.nav { display: grid; gap: 4px; }
.nav button { min-height: 40px; padding: 0 12px; text-align: left; color: var(--muted); background: transparent; border-radius: var(--radius-control); display: flex; align-items: center; gap: 11px; }
.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: 18px; text-align: center; font-size: 16px; }
.sidebar-footer { margin-top: auto; border-top: 1px solid var(--line); padding: 16px 10px 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.main { min-width: 0; padding: 34px clamp(18px, 3vw, 42px) 38px; }
.topbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; max-width: 1420px; margin: 0 auto 24px; }
.eyebrow { color: var(--accent-strong); font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
h1 { margin: 5px 0 6px; font-size: clamp(24px, 3vw, 32px); letter-spacing: -.035em; }
.subtitle { margin: 0; color: var(--muted); }
.top-actions { display: flex; gap: 8px; align-items: center; width: min(468px, 50vw); }
.instance-commands { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; flex: 1; }
.instance-commands button { width: 100%; }
.icon-button, .secondary, .primary, .danger-button { min-height: 36px; padding: 0 14px; border-radius: var(--radius-control); font-weight: 650; white-space: nowrap; }
.icon-button, .secondary { color: var(--text); background: var(--surface); border: 1px solid var(--line-strong); }
.icon-button { width: 38px; 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: 1420px; margin: auto; display: grid; gap: 18px; }
.banner { display: flex; gap: 12px; align-items: flex-start; background: var(--warning-soft); border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); border-radius: var(--radius-card); padding: 13px 16px; color: var(--text); }
.banner b { color: var(--warning); }
.banner p { margin: 3px 0 0; color: var(--muted); font-size: 13px; }
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.summary { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-card); padding: 16px; }
.summary-label { color: var(--muted); font-size: 12px; }
.summary-value { margin-top: 8px; display: flex; align-items: baseline; gap: 8px; font-size: 25px; font-weight: 750; letter-spacing: -.03em; }
.summary-note { color: var(--muted); font-size: 12px; }
.workspace { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-card); overflow: hidden; }
.toolbar { padding: 14px 16px; border-bottom: 1px solid var(--line); display: flex; flex-wrap: wrap; align-items: center; gap: 9px; }
.search { min-width: 220px; flex: 1; position: relative; }
.search input { width: 100%; height: 36px; border: 1px solid var(--line-strong); border-radius: var(--radius-control); background: var(--surface-2); padding: 0 12px 0 35px; color: var(--text); }
.search span { position: absolute; left: 12px; top: 8px; color: var(--muted); }
select { height: 36px; color: var(--text); background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius-control); padding: 0 30px 0 11px; }
.toolbar-spacer { flex: 1; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 760px; }
th { height: 42px; padding: 0 16px; text-align: left; color: var(--muted); font-size: 12px; font-weight: 650; background: var(--surface-2); border-bottom: 1px solid var(--line); }
td { padding: 14px 16px; border-bottom: 1px 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: 10px; min-width: 190px; }
.browser-logo { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; color: #fff; font-size: 12px; font-weight: 800; }
.chrome { background: conic-gradient(#e84c3d 0 33%, #f5c746 0 66%, #36a269 0); }
.edge { background: linear-gradient(140deg, #0c9b9e, #1768d2); }
.instance-name strong { display: block; font-size: 14px; }
.instance-name small, .mono { color: var(--muted); font-size: 12px; font-family: "Cascadia Mono", Consolas, monospace; }
.status { display: inline-flex; align-items: center; gap: 7px; border-radius: 999px; padding: 5px 9px; font-size: 12px; font-weight: 700; white-space: nowrap; }
.status::before { content: ""; width: 7px; height: 7px; 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.associated { color: var(--warning); background: var(--warning-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: 6px; opacity: .7; }
tr:hover .row-actions, tr.selected .row-actions { opacity: 1; }
.row-actions button { background: transparent; color: var(--muted); padding: 6px 8px; border-radius: 6px; }
.row-actions button:hover { color: var(--accent-strong); background: var(--accent-soft); }
.table-footer { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; color: var(--muted); font-size: 12px; }
.drawer-backdrop { position: fixed; inset: 0; background: rgba(16, 24, 40, .28); z-index: 10; opacity: 0; pointer-events: none; transition: opacity .16s ease; }
.drawer-backdrop.open { opacity: 1; pointer-events: auto; }
.drawer { position: fixed; z-index: 11; top: 0; right: 0; bottom: 0; width: min(480px, 100vw); background: var(--surface); box-shadow: var(--shadow); transform: translateX(100%); transition: transform .2s ease; overflow: auto; }
.drawer.open { transform: translateX(0); }
.drawer-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 28px 26px 20px; border-bottom: 1px solid var(--line); }
.drawer-head h2 { margin: 0 0 5px; font-size: 21px; letter-spacing: -.02em; }
.drawer-head p { margin: 0; color: var(--muted); font-size: 13px; }
.drawer-body { padding: 22px 26px; display: grid; gap: 22px; }
.form-section { display: grid; gap: 13px; }
.form-section h3 { margin: 0; font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
.field { display: grid; gap: 6px; }
.field label { font-weight: 650; }
.field label span { color: var(--danger); }
.field input, .field select { width: 100%; height: 38px; border: 1px solid var(--line-strong); background: var(--surface-2); color: var(--text); border-radius: var(--radius-control); padding: 0 11px; }
.field small, .help { color: var(--muted); font-size: 12px; line-height: 1.45; }
.field.error input { border-color: var(--danger); box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 18%, transparent); }
.field-error { color: var(--danger); font-size: 12px; }
.check-line { display: flex; gap: 9px; align-items: flex-start; color: var(--text); }
.check-line input { width: 16px; height: 16px; accent-color: var(--accent); margin-top: 2px; }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); padding-top: 18px; }
.detail-card { padding: 16px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 12px; display: grid; gap: 12px; }
.detail-line { display: flex; justify-content: space-between; gap: 18px; 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: 9px; }
.activity-item { display: grid; grid-template-columns: 9px 1fr; gap: 9px; font-size: 12px; color: var(--muted); }
.activity-item i { width: 8px; height: 8px; background: var(--accent); border-radius: 50%; margin-top: 4px; }
dialog { border: 0; border-radius: var(--radius-dialog); padding: 0; width: min(430px, calc(100vw - 32px)); color: var(--text); background: var(--surface); box-shadow: var(--shadow); }
dialog.edit-dialog { width: min(620px, calc(100vw - 32px)); }
dialog::backdrop { background: rgba(16, 24, 40, .42); }
.dialog-inner { padding: 24px; display: grid; gap: 17px; }
.dialog-inner h2 { margin: 0; font-size: 19px; }
.dialog-inner p { margin: 0; color: var(--muted); line-height: 1.55; }
.dialog-warning { padding: 11px 12px; border-radius: 9px; background: var(--danger-soft); color: var(--danger); font-size: 12px; line-height: 1.45; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.edit-form { display: grid; gap: 18px; }
.edit-form .path-field { grid-template-columns: minmax(0, 1fr) auto; }
.readonly-value { background: var(--surface-3) !important; color: var(--muted) !important; cursor: default; }
.form-note { padding: 10px 12px; border-radius: 9px; background: var(--warning-soft); color: var(--warning); font-size: 12px; line-height: 1.5; }
.empty-state { display: none; text-align: center; padding: 48px 20px; color: var(--muted); }
.empty-state strong { display: block; color: var(--text); font-size: 16px; margin-bottom: 7px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.toast { position: fixed; right: 22px; bottom: 22px; z-index: 30; max-width: min(420px, calc(100vw - 44px)); padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid var(--accent); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); color: var(--text); opacity: 0; transform: translateY(8px); pointer-events: none; transition: opacity .16s ease, transform .16s ease; }
.toast.show { opacity: 1; transform: translateY(0); }
.page-hidden { display: none !important; }
.settings-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(260px, .8fr); gap: 18px; }
.settings-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-card); padding: 22px; display: grid; gap: 20px; }
.settings-card h2 { margin: 0; font-size: 18px; letter-spacing: -.02em; }
.settings-card > p { margin: -12px 0 0; color: var(--muted); line-height: 1.5; }
.path-field { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; }
.radio-row { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; min-height: 38px; }
.radio-row label { font-weight: 500; display: inline-flex; gap: 7px; align-items: center; }
.settings-status { min-height: 42px; padding: 11px 12px; border-radius: 9px; background: var(--success-soft); color: var(--success); font-size: 12px; line-height: 1.45; }
.settings-status.warning { background: var(--warning-soft); color: var(--warning); }
.settings-actions { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); padding-top: 18px; }
.setting-list { display: grid; gap: 12px; }
.setting-list .detail-line { padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.setting-list .detail-line:last-child { border-bottom: 0; padding-bottom: 0; }
@media (max-width: 800px) { .settings-grid { grid-template-columns: 1fr; } }
@media (max-width: 1007px) {
.app { grid-template-columns: 70px minmax(0, 1fr); }
.sidebar { padding-inline: 10px; }
.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, 1fr)); }
}
@media (max-width: 640px) {
.app { display: block; }
.sidebar { height: 58px; padding: 10px 14px; border-right: 0; border-bottom: 1px 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: 38px; }
.nav button span:not(.nav-icon) { display: none; }
.main { padding: 28px 12px; }
.topbar { display: block; }
.top-actions { margin-top: 16px; width: 100%; }
.instance-commands { grid-template-columns: 1fr; }
.summary-grid { gap: 8px; }
.summary { padding: 12px; }
.summary-value { font-size: 21px; }
.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: .01ms !important; animation-duration: .01ms !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><div class="instance-commands" id="instanceCommands"><button class="primary" id="newInstance">+ 新建实例</button><button class="secondary" id="refresh" title="刷新实例状态 (F5)">↻ 刷新状态</button></div></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="associated">外部已关联</option><option value="occupied">被占用</option><option value="exited">已退出</option></select></div>
<div class="table-wrap"><table><caption id="listTitle" class="sr-only">浏览器实例列表</caption><thead><tr><th scope="col">实例名称</th><th scope="col">浏览器类型</th><th scope="col">用户数据目录</th><th scope="col">调试端口</th><th scope="col">状态</th><th scope="col">操作</th></tr></thead><tbody id="instanceRows"></tbody></table><div class="empty-state" id="emptyState"><strong>没有匹配的实例</strong><span>尝试清除筛选,或新建一个浏览器实例。</span></div></div>
<div class="table-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><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>
</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><button type="button" class="secondary search-setting" data-target="defaultDataDir">搜索</button></div><small>新建实例时作为默认建议路径;不会移动或删除已有 profile。</small></div>
<div class="field"><label for="logDir">日志目录</label><div class="path-field"><input id="logDir" value="D:\\Chub\\logs"><button type="button" class="secondary browse-setting" data-target="logDir">选择目录</button><button type="button" class="secondary search-setting" data-target="logDir">搜索</button></div><small>日志仅保存必要诊断信息,并过滤敏感参数。</small></div>
</div>
<div class="form-section"><h3>代理</h3><p class="help">仅保存无认证代理;名称可在实例表单中选择。</p><div class="field"><label for="proxyName">代理名称</label><input id="proxyName" placeholder="例如:新加坡出口"></div><div class="field"><label for="proxyServer">代理地址</label><input id="proxyServer" placeholder="例如:http://127.0.0.1:8080"><small>支持 http、https、socks4、socks5 的 <span class="mono">scheme://host:port</span>;不允许用户名、密码或路径。</small></div><div class="dialog-actions"><button type="button" class="secondary" id="proxyCancel" hidden>取消编辑</button><button type="button" class="secondary" id="proxySave">添加代理</button></div><div class="setting-list" id="proxyList" aria-label="已保存代理"></div></div>
<div class="settings-actions"><button type="button" class="secondary" id="discardSettings">取消</button><button type="submit" class="primary">保存设置</button></div>
</form>
<aside class="settings-card"><div><h2>运行行为</h2><p>影响后续启动和应用关闭时的默认行为。</p></div><div class="setting-list"><label class="check-line"><input type="checkbox" id="closeOnExit"><span><b>退出 Chub 时关闭本工具启动的实例</b><br><small class="help">默认关闭。不会影响外部浏览器。</small></span></label><div class="field"><label for="remoteDebugPort">远程调试起始端口</label><input id="remoteDebugPort" type="number" min="1024" max="65535" value="9666" inputmode="numeric"><small>留空时使用 9666。启动实例仅绑定 127.0.0.1;端口被占用时顺序尝试下一个可用端口。</small></div><div class="settings-status" id="settingsStatus">设置已保存 · 最后更新:刚刚</div></div></aside>
</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>
<dialog class="edit-dialog" id="editDialog" aria-labelledby="editTitle"><form class="dialog-inner edit-form" id="editForm"><div><h2 id="editTitle">编辑实例</h2><p id="editSubtitle">修改下次启动使用的保存配置。</p></div><div class="field"><label for="editName">实例名称 <span>*</span></label><input id="editName" required><small>用于在实例列表中识别此浏览器环境。</small></div><div class="field"><span>浏览器类型</span><div class="radio-row" role="radiogroup" aria-label="浏览器类型"><label><input type="radio" name="editKind" value="chrome" id="editChrome"> Chrome</label><label><input type="radio" name="editKind" value="edge" id="editEdge"> Edge</label></div></div><div class="field"><label for="editDataDir">User Data Dir <span>*</span></label><div class="path-field"><input id="editDataDir" required><button type="button" class="secondary" id="chooseEditDir">选择目录</button></div><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="editURL">启动 URL(可选)</label><input id="editURL" type="url" placeholder="https://example.com"><small>仅支持 http/https;留空时启动浏览器默认页。</small></div><div class="field"><label for="editProxy">代理</label><select id="editProxy"><option value="">无代理</option></select><small>选择已保存的无认证代理;修改后下次启动生效。</small></div><div class="field"><label for="editPort">当前调试端口</label><input class="readonly-value" id="editPort" readonly><small>实际端口由全局起始端口在启动时分配,不能在此固定。</small></div><div class="form-note" id="editReadOnlyNote" hidden>该实例正在运行或为外部关联。为避免配置与运行身份不一致,浏览器类型和 User Data Dir 已锁定。</div><div class="dialog-actions"><button type="button" class="secondary" id="editCancel">取消</button><button type="submit" class="primary" id="editSave">保存更改</button></div></form></dialog>
<dialog id="discardDialog" aria-labelledby="discardTitle"><div class="dialog-inner"><h2 id="discardTitle">放弃未保存的更改?</h2><p>可以先保存配置,或放弃本次编辑并关闭对话框。</p><div class="dialog-actions"><button class="secondary" id="keepEditing">继续编辑</button><button class="secondary" id="discardChanges">不保存</button><button class="primary" id="saveChanges">保存</button></div></div></dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
const proxies = [
{ id: 'proxy-sg', name: '新加坡出口', server: 'http://127.0.0.1:8080' },
{ id: 'proxy-test', name: '测试 SOCKS5', server: 'socks5://127.0.0.1:1080' }
];
const instances = [
{ id: 'inst-chrome-shop', name: 'Shopee · SG 主店', kind: 'chrome', browser: 'Chrome', status: 'running', statusText: '运行中(托管)', pid: '18420', port: '9666', dir: 'D:\\Chub\\profiles\\shopee-sg', started: '今天 09:42', url: 'https://seller.shopee.sg/', proxyId: 'proxy-sg', executable: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' },
{ id: 'inst-edge-review', name: '素材审核 · Edge', kind: 'edge', browser: 'Edge', status: 'starting', statusText: '启动中', pid: '—', port: '检测中…', dir: 'D:\\Chub\\profiles\\asset-review', started: '正在启动', url: 'https://example.com/review', executable: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' },
{ id: 'inst-chrome-ph', name: 'Shopee · PH 备用', kind: 'chrome', browser: 'Chrome', status: 'associated', statusText: '外部已关联', pid: '16108', port: '9668', dir: 'D:\\Chub\\profiles\\shopee-ph', started: '昨天 18:16', url: 'https://seller.shopee.ph/', executable: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' },
{ id: 'inst-edge-test', name: '测试环境 · Edge', kind: 'edge', browser: 'Edge', status: 'exited', statusText: '已退出', pid: '—', port: '—', dir: 'D:\\Chub\\profiles\\edge-test', started: '昨天 16:04', url: 'https://localhost:3000', executable: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', exit: '退出码 0' }
];
let selectedId = 'inst-chrome-shop';
let modalAction = 'stop';
let editingID = '';
let editSnapshot = null;
let editingProxyID = '';
const $ = (id) => document.getElementById(id);
const statusClass = (s) => s;
const proxyLabel = (id) => proxies.find(p => p.id === id)?.name || '无代理';
function renderProxyOptions(select, selected = '') { select.innerHTML = '<option value="">无代理</option>' + proxies.map(p => `<option value="${p.id}">${p.name} · ${p.server}</option>`).join(''); select.value = selected; }
function renderProxies() { const list = $('proxyList'); list.innerHTML = proxies.map(p => { const uses = instances.filter(i => i.proxyId === p.id).length; return `<div class="detail-line"><span><b>${p.name}</b><br><small class="mono">${p.server}</small></span><span><small>${uses ? `已被 ${uses} 个实例使用` : '未使用'}</small> <button type="button" class="secondary" data-proxy-edit="${p.id}">编辑</button><button type="button" class="secondary" data-proxy-delete="${p.id}">删除</button></span></div>`; }).join('') || '<small class="help">尚无代理。实例将以无代理方式启动。</small>'; list.querySelectorAll('[data-proxy-edit]').forEach(b => b.onclick = () => { const p = proxies.find(item => item.id === b.dataset.proxyEdit); editingProxyID = p.id; $('proxyName').value = p.name; $('proxyServer').value = p.server; $('proxySave').textContent = '保存代理'; $('proxyCancel').hidden = false; $('proxyName').focus(); }); list.querySelectorAll('[data-proxy-delete]').forEach(b => b.onclick = () => { const p = proxies.find(item => item.id === b.dataset.proxyDelete), uses = instances.filter(i => i.proxyId === p.id).length; if (uses) return showToast(`“${p.name}”仍被 ${uses} 个实例使用,不能删除。`); proxies.splice(proxies.indexOf(p), 1); renderProxies(); showToast(`已删除代理“${p.name}”。`); }); }
function filtered() {
const q = $('search').value.trim().toLowerCase(), browser = $('browserFilter').value, status = $('statusFilter').value;
return instances.filter(x => (!q || [x.name, x.browser, x.pid, x.port, x.dir].join(' ').toLowerCase().includes(q)) && (browser === 'all' || x.kind === browser) && (status === 'all' || x.status === status));
}
function selectRow(id, body) {
selectedId = id;
body.querySelectorAll('tr').forEach(row => row.classList.toggle('selected', row.dataset.id === id));
}
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.title = '双击或按 Enter 编辑实例'; tr.innerHTML = `
<td><div class="instance-name"><span class="browser-logo ${x.kind}" aria-hidden="true">${x.kind === 'chrome' ? 'C' : 'E'}</span><div><strong>${x.name}</strong><small>${x.id}</small></div></div></td>
<td>${x.browser}</td><td class="mono">${x.dir}</td><td class="mono">${x.port || '—'}</td><td><span class="status ${statusClass(x.status)}">${x.statusText}</span></td>
<td><div class="row-actions"><button data-action="edit" aria-label="编辑 ${x.name}">编辑</button><button data-action="start" aria-label="${x.status === 'associated' ? '重新检测' : x.status === 'running' ? '停止' : '启动'} ${x.name}">${x.status === 'associated' ? '重新检测' : x.status === 'running' ? '■ 停止' : '▶ 启动'}</button><button data-action="delete" aria-label="删除 ${x.name}">删除</button></div></td>`;
tr.addEventListener('click', e => { if (!e.target.closest('button')) selectRow(x.id, body); });
tr.addEventListener('dblclick', e => { if (!e.target.closest('button')) openEdit(x.id); });
tr.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); openEdit(x.id); } else if (e.key === ' ') { e.preventDefault(); selectRow(x.id, body); } });
tr.querySelectorAll('button').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); selectRow(x.id, body); if (b.dataset.action === 'edit') openEdit(x.id); else if (b.dataset.action === 'delete') showToast(`原型演示:将确认删除“${x.name}”的 Chub 配置,不会删除 User Data Dir。`); else if (x.status === 'running') { x.status = 'stopping'; x.statusText = '停止中'; renderRows(); setTimeout(() => { x.status = 'exited'; x.statusText = '已退出'; x.pid = '—'; x.port = '—'; renderRows(); showToast(`“${x.name}”已优雅退出。`); }, 700); } else openConfirm(x.id, 'start'); }));
body.appendChild(tr);
});
$('emptyState').style.display = rows.length ? 'none' : 'block'; $('resultCount').textContent = `显示 ${rows.length} 个实例`;
}
function openDetails(id) { selectedId = id; const x = instances.find(i => i.id === id), externallyAssociated = x.status === 'associated'; $('drawer').innerHTML = `<div class="drawer-head"><div><h2>${x.name}</h2><p>${x.browser} · ${x.id}</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭详情">×</button></div><div class="drawer-body"><div><span class="status ${x.status}">${x.statusText}</span></div><div class="detail-card"><div class="detail-line"><span>进程 PID</span><span class="mono">${x.pid}</span></div><div class="detail-line"><span>调试端口</span><span class="mono">${x.port || '—'}</span></div><div class="detail-line"><span>浏览器</span><span>${x.browser}</span></div><div class="detail-line"><span>user data dir</span><span class="mono">${x.dir}</span></div><div class="detail-line"><span>启动地址</span><span class="mono">${x.url}</span></div><div class="detail-line"><span>可执行文件</span><span class="mono">${x.executable}</span></div>${x.exit ? `<div class="detail-line"><span>退出结果</span><span>${x.exit}</span></div>` : ''}</div><div class="form-section"><h3>实例操作</h3><div class="drawer-actions" style="border-top:0;padding-top:0;justify-content:flex-start"><button class="secondary" id="restartBtn" ${x.status === 'starting' || externallyAssociated ? 'disabled' : ''}>↻ 重启</button><button class="danger-button" id="stopBtn" ${x.status === 'exited' || externallyAssociated ? 'disabled' : ''}>关闭实例</button></div>${externallyAssociated ? '<small class="help">外部关联实例仅供观察,Chub 不会接管其生命周期。</small>' : ''}</div><div class="form-section"><h3>最近活动</h3><div class="activity"><div class="activity-item"><i></i><span>状态已同步 · ${x.started}</span></div><div class="activity-item"><i></i><span>${externallyAssociated ? '已验证本地 CDP 端点;未接管外部浏览器。' : x.status === 'occupied' ? '检测到外部进程占用该 user data dir' : '实例配置已通过身份校验'}</span></div></div></div></div>`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('stopBtn').onclick = () => openConfirm(x.id, false); $('restartBtn').onclick = () => openConfirm(x.id, 'restart'); }
function editIsLocked(x) { return ['running', 'starting', 'associated'].includes(x.status); }
function editIsDirty() {
if (!editingID || !editSnapshot) return false;
return $('editName').value !== editSnapshot.name || $('editDataDir').value !== editSnapshot.dir || $('editURL').value !== editSnapshot.url || $('editProxy').value !== editSnapshot.proxyId || document.querySelector('input[name="editKind"]:checked')?.value !== editSnapshot.kind;
}
function focusEditedRow(id) { requestAnimationFrame(() => document.querySelector(`tr[data-id="${id}"]`)?.focus()); }
function finishEdit() { const id = editingID; $('editDialog').close(); editingID = ''; editSnapshot = null; focusEditedRow(id); }
function openEdit(id) {
const x = instances.find(i => i.id === id); if (!x) return;
selectedId = id; editingID = id; editSnapshot = { name: x.name, dir: x.dir, url: x.url || '', kind: x.kind, proxyId: x.proxyId || '' };
const locked = editIsLocked(x); $('editName').value = x.name; $('editDataDir').value = x.dir; $('editURL').value = x.url || ''; renderProxyOptions($('editProxy'), x.proxyId || ''); $('editPort').value = x.port || '—'; $('editChrome').checked = x.kind === 'chrome'; $('editEdge').checked = x.kind === 'edge';
$('editSubtitle').textContent = locked ? '当前运行状态下可查看配置,但身份约束字段不可修改。' : '修改下次启动使用的保存配置。';
$('editChrome').disabled = locked; $('editEdge').disabled = locked; $('editDataDir').disabled = locked; $('chooseEditDir').disabled = locked; $('editReadOnlyNote').hidden = !locked;
$('editDialog').showModal(); setTimeout(() => $('editName').focus(), 0);
}
function saveEdit() {
const x = instances.find(i => i.id === editingID); if (!x) return finishEdit();
const name = $('editName').value.trim(), dir = $('editDataDir').value.trim(), url = $('editURL').value.trim();
if (!name || !dir) { showToast('请填写实例名称和 User Data Dir。'); (!name ? $('editName') : $('editDataDir')).focus(); return; }
const locked = editIsLocked(x); x.name = name; x.url = url; x.proxyId = $('editProxy').value;
if (!locked) { x.dir = dir; x.kind = document.querySelector('input[name="editKind"]:checked')?.value || x.kind; x.browser = x.kind === 'edge' ? 'Edge' : 'Chrome'; }
if ($('discardDialog').open) $('discardDialog').close(); renderRows(); const savedName = x.name; finishEdit(); showToast(`已保存“${savedName}”的实例配置。`);
}
function requestEditClose() { if (editIsDirty()) { $('editDialog').close(); $('discardDialog').showModal(); return; } finishEdit(); }
function openLaunch() { $('drawer').innerHTML = `<div class="drawer-head"><div><h2>新建浏览器实例</h2><p>配置完成后,Chub 会在本机启动一个隔离环境。</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭新建实例">×</button></div><form class="drawer-body" id="launchForm"><div class="form-section"><h3>浏览器配置</h3><div class="field"><span>浏览器类型 <span>*</span></span><div class="radio-row" role="radiogroup" aria-label="浏览器类型"><label><input type="radio" name="kind" value="chrome" checked> Chrome</label><label><input type="radio" name="kind" value="edge"> Edge</label></div></div><div class="field"><label for="exe">可执行文件</label><input id="exe" value="自动发现" aria-describedby="exeHelp"><small id="exeHelp">留空时按浏览器类型搜索标准安装路径。</small></div></div><div class="form-section"><h3>运行环境</h3><div class="field"><label for="dataDir">User Data Dir <span>*</span></label><div class="path-field"><input id="dataDir" placeholder="例如:D:\\Chub\\profiles\\new-instance" required><button type="button" class="secondary" id="chooseInstanceDir">选择目录</button></div><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="profile">profile-directory</label><input id="profile" value="Default"></div><div class="field"><label for="url">启动 URL(可选)</label><input id="url" type="url" placeholder="https://example.com"><small>仅支持 http/https;留空时启动浏览器默认页。</small></div><div class="field"><label for="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'); if (!data.value.trim()) { data.focus(); data.parentElement.classList.add('error'); const msg = document.createElement('div'); msg.className = 'field-error'; msg.textContent = '请填写 User Data Dir。'; data.parentElement.appendChild(msg); return; } const proxy = proxyLabel($('launchProxy').value); hideDrawer(); setTimeout(() => showToast(`启动请求已提交(${proxy}),实例进入 STARTING 状态。`), 80); }; }
function showDrawer() { $('drawer').classList.add('open'); $('backdrop').classList.add('open'); $('drawer').setAttribute('aria-hidden', 'false'); setTimeout(() => $('drawer').querySelector('button, input, select')?.focus(), 60); }
function hideDrawer() { $('drawer').classList.remove('open'); $('backdrop').classList.remove('open'); $('drawer').setAttribute('aria-hidden', 'true'); }
function openConfirm(id, action) { const x = instances.find(i => i.id === id); modalAction = action; $('dialogTitle').textContent = action === 'restart' ? '重启浏览器实例?' : action === 'start' ? '启动浏览器实例?' : '关闭浏览器实例?'; $('dialogText').textContent = action === 'restart' ? `将先请求“${x.name}”正常退出,再使用相同配置重新启动。` : action === 'start' ? `将使用已保存配置启动“${x.name}”。` : `将请求“${x.name}”正常退出。未保存的网页内容由浏览器自行处理。`; $('dialogWarning').hidden = action !== false; $('dialogConfirm').textContent = action === false ? '优雅关闭' : action === 'restart' ? '确认重启' : '启动实例'; $('dialogConfirm').className = action === false ? 'danger-button' : 'primary'; $('confirmDialog').showModal(); }
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 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;
document.addEventListener('keydown', e => { if (e.key === 'F5' && !$('editDialog').open && !$('discardDialog').open) { e.preventDefault(); refreshInstances(); } });
document.querySelectorAll('[data-nav]').forEach(b => b.onclick = () => { document.querySelectorAll('[data-nav]').forEach(n => n.removeAttribute('aria-current')); b.setAttribute('aria-current', 'page'); setPage(b.dataset.nav); });
document.querySelectorAll('.browse-setting').forEach(b => b.onclick = () => showToast(`原型演示:将打开${b.dataset.target.includes('Path') ? '文件' : '目录'}选择器。`));
document.querySelectorAll('.search-setting').forEach(b => b.onclick = () => { if (b.dataset.timer) { clearTimeout(Number(b.dataset.timer)); delete b.dataset.timer; b.textContent = '搜索'; showToast('已取消当前搜索。'); return; } b.textContent = '取消'; showToast('正在搜索可用路径…'); b.dataset.timer = String(setTimeout(() => { delete b.dataset.timer; b.textContent = '搜索'; showToast('搜索完成:已更新当前字段。'); }, 1600)); });
$('proxySave').onclick = () => { const name = $('proxyName').value.trim(), server = $('proxyServer').value.trim(); if (!name || !server || !/^(https?|socks[45]):\/\/[^\/@?#:]+(?::\d+)$/.test(server)) { showToast('请填写名称和无认证的 scheme://host:port 代理地址。'); return; } if (proxies.some(p => p.name === name && p.id !== editingProxyID)) { showToast('代理名称已存在,请使用不同名称。'); return; } if (editingProxyID) { const p = proxies.find(item => item.id === editingProxyID); p.name = name; p.server = server; } else proxies.push({ id: `proxy-${Date.now()}`, name, server }); editingProxyID = ''; $('proxyName').value = ''; $('proxyServer').value = ''; $('proxySave').textContent = '添加代理'; $('proxyCancel').hidden = true; renderProxies(); showToast('代理已保存;后续启动将使用最新端点。'); };
$('proxyCancel').onclick = () => { editingProxyID = ''; $('proxyName').value = ''; $('proxyServer').value = ''; $('proxySave').textContent = '添加代理'; $('proxyCancel').hidden = true; };
$('settingsForm').addEventListener('submit', e => { e.preventDefault(); $('settingsStatus').textContent = '设置已保存 · 最后更新:刚刚'; $('settingsStatus').classList.remove('warning'); showToast('设置已保存,后续新建实例将使用新的默认值。'); });
$('discardSettings').onclick = () => { $('settingsForm').reset(); $('settingsStatus').textContent = '已取消本次修改,恢复到上次保存值。'; $('settingsStatus').classList.add('warning'); showToast('已取消设置修改。'); };
renderRows(); renderProxies();
</script>
</body>
</html>