docs: define instance refresh and edit task

This commit is contained in:
QiuSW
2026-07-25 16:21:38 +08:00
parent 4063b851eb
commit 1a453cf8f0
9 changed files with 120 additions and 15 deletions
+3
View File
@@ -14,6 +14,7 @@
| BR-010 | 提供 CLI 或本地 API,供脚本启动、查询和关闭实例。 | P1 |
| BR-011 | Chub 启动的 Chrome/Edge 必须启用仅限 loopback 的本地 CDP;端口从设置的起始端口分配,空值默认 9666。 | P1 |
| BR-012 | 同一 user data dir 被外部浏览器占用时,只能在其 CDP 端点可验证时关联为外部实例;不得接管生命周期。 | P1 |
| BR-013 | 用户可以刷新已保存实例的状态,并编辑未运行实例的名称、浏览器类型、user data dir 和启动 URL;刷新和编辑不得扩大到未授权的浏览器进程。 | P1 |
## 验收标准
@@ -27,6 +28,8 @@
- 异常退出后状态在合理时间内变为 EXITED/FAILED。
- 参数中不出现密码、Cookie、Token 或代理 userinfo。
- UI 覆盖加载、空、占用、启动失败、关闭中、成功和权限错误。
- 刷新仅检查 Chub 已保存的实例;任务执行中不可重复触发,过期结果不得覆盖已经删除或编辑后的记录。
- 实例编辑支持保存、取消和 Escape;有未保存修改时必须先确认,运行中或外部关联实例不能修改浏览器类型或 user data dir。
## 不做
+4 -1
View File
@@ -13,7 +13,8 @@ Platform Windows
├─ Job Object
└─ profile occupancy inspector
├─ loopback CDP endpoint inspector
└─ remote-debug port allocator
├─ remote-debug port allocator
└─ instance status refresher
↓
Chrome / Edge processes
```
@@ -36,6 +37,8 @@ UI/CLI 不直接操作 `exec.Cmd`、Win32 handle 或数据库。Application 层
- Force stop:使用实例 Job Object 或已校验进程树,不按名称全局终止。
- Restart:旧实例退出或完成超时处理后重新执行同一配置。
- App shutdown:取消监控,不默认终止用户浏览器。
- Refresh:以 UI 传入的保存实例快照为范围,在后台读取 Chub registry、对应 profile 占用证据与已知 loopback CDP 端点,返回状态 DTO。刷新不按浏览器进程名或端口范围枚举;结果以实例 ID 与快照版本关联,UI 丢弃已删除或已编辑行的过期结果。
- Edit:UI 只提交可持久化的实例配置 DTO;运行时 PID、关联端口、占用来源和状态由刷新/启动结果返回,不作为编辑表单保存字段。编辑时保留未公开的非敏感启动选项,活跃或外部关联实例的身份约束字段保持只读。
Chrome 和 Edge 共用大部分 Chromium 参数,但 executable 默认路径、进程名称和安装方式不同,通过 `BrowserDefinition` 封装差异。CDP 端点只绑定 `127.0.0.1`;端口号和 `/json/version` 是诊断数据,WebSocket URL 不进入持久化、事件或普通 UI 文案。
+1
View File
@@ -38,6 +38,7 @@
| --- | --- | --- | --- |
| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE |
| T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DONE |
| T-303 | 实例状态刷新与编辑对话框 | T-302 | DOING |
## Backlog
+5
View File
@@ -9,6 +9,7 @@
| US-005 | 用户可以重启指定实例,并确认旧实例已经退出。 | P0 |
| US-006 | 用户可以通过 CLI/API 查询、启动和关闭实例。 | P1 |
| US-007 | 用户可以看到实例关联的本地调试端口;同目录外部浏览器已启用调试时,Chub 只关联并展示状态。 | P1 |
| US-008 | 用户可以刷新已保存实例的运行状态,并通过对话框编辑非运行实例的基本配置而不影响其他浏览器。 | P1 |
## 验收场景
@@ -27,3 +28,7 @@ CLI/API 返回稳定错误码和结构化数据;未知 PID、身份不匹配
### US-007 本地 CDP 与外部关联
给定未占用的 user data dir,启动时从设置中的起始端口分配一个 loopback CDP 端口,并显示实际端口。给定同目录已由外部浏览器占用,只有 `DevToolsActivePort` 与 `/json/version` 可验证时才显示“外部已关联”;否则拒绝第二次启动。无论哪种外部状态,Chub 都不提供生命周期控制。
### US-008 刷新与编辑实例
给定已保存的实例,用户点击刷新或按 F5 后,Chub 只检查这些实例并更新运行状态、PID、端口与占用来源。用户双击行、按 Enter 或点击编辑图标可以打开编辑对话框;保存名称、浏览器类型、User Data Dir 和启动 URL 后更新本地配置。实际调试端口只读显示。若实例正在运行、启动中或为外部关联,浏览器类型和 User Data Dir 保持不可编辑。Escape 在无修改时关闭对话框,有修改时要求用户选择保存、不保存或继续编辑。
+3
View File
@@ -8,6 +8,7 @@
| IX-004 | 占用提示 | 查看 PID/来源和关联端口并重试 | 外部已关联、被占用、身份未知、已释放 | P0 |
| IX-005 | 设置 | 默认路径、日志、数据目录和远程调试起始端口 | 保存成功、路径无效、端口无效、权限拒绝 | P1 |
| IX-006 | 本地 CDP 启动 | 从起始端口分配、校验端点、展示实际端口 | 分配中、已关联、端口冲突、端点失效、外部不接管 | P1 |
| IX-007 | 实例刷新与编辑 | F5/刷新、双击/Enter/编辑图标、目录选择、保存/取消/Escape | 空闲、刷新中、部分失败、字段错误、脏表单、只读运行态、保存成功 | P1 |
## 通用规则
@@ -18,3 +19,5 @@
- 验证 960x640、600x500、100%–200% 缩放、键盘、深色和高对比度回退。
- 不依赖悬停或触控手势完成关键任务。
- 实例列表使用外层边框、文字状态和图标区分归属;端口为空时显示“—”,外部关联时操作区不显示关闭或重启。
- 刷新和编辑是独立命令:刷新为只读状态检查,编辑只保存配置;两者均不接管外部实例。F5 与刷新按钮共用一个去重命令。
- 双击不是唯一入口:单击只选择行,Enter 和编辑图标打开对话框。打开后焦点位于名称,关闭后返回调用行;有未保存编辑时 Escape 先请求确认。
+4
View File
@@ -8,6 +8,8 @@ MVP 首先提供本地 Go application service 和 CLI;是否增加 loopback HT
`LaunchSpec` 使用已分配的 `RemoteDebugPort`;端口分配不属于 UI,也不允许由 `ExtraArgs` 覆盖。`InstanceView` 返回实际关联端口、PID、占用来源和归属。外部关联只公开经 `DevToolsActivePort` 与 `/json/version` 验证的端口,不公开 CDP WebSocket URL。
UI 编辑使用独立的 `EditableInstance` DTO(`ID`、名称、浏览器类型、User Data Dir、启动 URL),不将运行时 PID、状态、来源或实际远程调试端口写回配置。状态刷新使用 `RefreshInstanceStatus(ctx, []InstanceSnapshot) ([]InstanceStatusResult, error)` 形状:每个结果以 `ID` 和快照版本对应,错误可按行返回;实现只能检查调用方提供的实例范围。
## Application service
```go
@@ -26,6 +28,8 @@ type BrowserManager interface {
参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。CDP 端点检测和端口分配均通过 platform port 执行,UI 只消费 DTO 结果。
状态刷新和目录选择必须通过后台 adapter 回到 UI 结果通道;UI frame 不直接做进程、文件或 CDP I/O。刷新不构成外部实例接管授权。
## CLI 当前合约
```text
+2 -2
View File
@@ -3,11 +3,11 @@
## 快照
- 日期:2026-07-25
- 阶段:Phase 3 真实实例操作(T-302、T-301 已完成;T-201 至 T-208 已完成)
- 阶段:Phase 3 真实实例操作(T-303 进行中;T-302、T-301 已完成;T-201 至 T-208 已完成)
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301、T-302 已完成
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑启动/删除图标,并以外层边框和表头分隔组织。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
- blocker:无;下一步可接入关闭、重启、退出状态回传和 CLI BrowserManager adapter。
- blocker:无;T-303 正在接入实例状态刷新和可保存的编辑对话框;后续可接入关闭、重启、退出状态回传和 CLI BrowserManager adapter。
## 当前目录
+43
View File
@@ -0,0 +1,43 @@
---
id: T-303
title: 实例状态刷新与编辑对话框
phase: 3
deps: [T-302]
status: DOING
created: 2026-07-25
owner: codex
---
## 需求与背景
实例页需要把“新建实例”与“刷新实例状态”作为同一命令区的两个等宽操作:默认窗口下各占可用宽度的 50%,窄窗口自动堆叠。刷新需要重新检查已保存且用户已授权的实例状态,而不是扫描或控制系统中所有 Chrome/Edge。
用户双击实例列表记录时需要打开实例编辑对话框,查看和修改名称、浏览器类型、User Data Dir、启动 URL 等保存配置;目录字段需要继续使用 Windows 原生目录选择器。对话框必须支持保存、取消和 Escape 退出,且不应静默丢弃已修改内容。
## 方案与边界
- UI 增加集中管理的异步 `InstanceRefresher` 回调和结果通道。刷新操作只针对当前保存的 `InstanceRow` 快照;后台依次检查 Chub registry、该行规范化 User Data Dir 的外部占用证据和已知的 loopback CDP 端点,不枚举全局进程名或端口。
- 刷新期间禁用刷新命令并显示“刷新中…”。成功后更新行的状态、PID、实际关联端口和来源;删除或编辑后到达的过期结果不得覆盖当前行。刷新失败保留上次有效行状态,并给出非模态反馈。
- 运行时端口是启动时由全局“远程调试起始端口”分配的实际值,编辑对话框以带 Label 的只读字段展示,不作为每实例持久化配置。若未来需要固定端口,必须另行设计端口保留、冲突与重启策略。
- 双击行或按 Enter 打开编辑对话框;单击只设置当前行。为避免把双击作为唯一入口,行操作区增加独立的编辑图标。启动、删除和编辑图标消费各自输入,不触发行的默认操作。
- 编辑对话框保存名称、浏览器类型、User Data Dir 和启动 URL;字段有 Label、邻近帮助文本和就地校验。目录选择器回填输入框。保存时复用实例变更回调,保留配置中未由此对话框编辑的 profile、代理、headless 与额外参数。
- 对“启动中”“运行中”“外部已关联”实例不允许修改浏览器类型和 User Data Dir,避免配置与正在运行的浏览器身份不一致;外部关联始终不取得生命周期控制权。
- Escape 在未修改时等价于取消;有未保存修改时先显示“保存 / 不保存 / 继续编辑”确认。对话框打开时焦点位于实例名称,关闭后焦点返回调用行;F5 触发状态刷新。
- Gio 的 `Clickable`、`Editor`、焦点恢复对象、编辑快照和脏状态均由 `Shell` 持久持有;所有检查和目录选择均在 UI frame 外执行。
## 验收要点
- 宽窗口中“新建实例”和“刷新实例状态”各占命令区一半;`<=640epx` 时纵向堆叠,按钮仍可键盘与触控访问。刷新按 F5 和按钮均可触发,同一时间只运行一个刷新任务。
- 刷新仅检查 Chub 配置中的行,能更新托管运行、已退出、外部已关联、外部/未知占用和调试不可用状态,以及 PID、端口和来源;不执行全局 Chrome/Edge 扫描、关闭或接管。
- 双击任一行、按 Enter 或点击编辑图标均打开对应编辑对话框;启动和删除图标不会误开对话框。
- 编辑对话框显示名称、浏览器类型 RadioButton、User Data Dir 输入/选择、启动 URL、只读实际调试端口和状态说明。有效保存后列表和本地配置更新;取消或 Escape 不修改数据;有未保存修改时不静默丢弃。
- 活跃或外部关联实例的受身份约束字段不可编辑且有恢复说明;删除仍只删除 Chub 配置,不删除 User Data Dir。
- 覆盖刷新成功、部分失败、重复刷新、行被删除后的过期结果、目录选择取消、字段校验失败、保存失败、仅键盘操作和窄窗口布局。
- `gofmt`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、`scripts/smoke-browser.ps1` 与 `scripts/smoke-windows.ps1` 通过。
## 执行记录
- 状态:DOING
- 变更:待实现。
- 验证:待执行。
- 阻塞:无。
+55 -12
View File
@@ -75,7 +75,9 @@
.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; }
.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; }
@@ -153,12 +155,17 @@
.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; }
@@ -197,8 +204,8 @@
.nav button span:not(.nav-icon) { display: none; }
.main { padding: 28px 12px; }
.topbar { display: block; }
.top-actions { margin-top: 16px; }
.top-actions .primary { flex: 1; }
.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; }
@@ -225,7 +232,7 @@
<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>
<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="实例工作区">
@@ -238,7 +245,7 @@
</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 class="toolbar-spacer"></div><button class="secondary" id="refresh">↻ 刷新状态</button></div>
<div class="toolbar"><div class="search"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="搜索实例、路径、PID 或端口" aria-label="搜索实例、路径、PID 或端口"></div><select id="browserFilter" aria-label="筛选浏览器"><option value="all">全部浏览器</option><option value="chrome">Chrome</option><option value="edge">Edge</option></select><select id="statusFilter" aria-label="筛选状态"><option value="all">全部状态</option><option value="running">运行中</option><option value="starting">启动中</option><option value="associated">外部已关联</option><option value="occupied">被占用</option><option value="exited">已退出</option></select></div>
<div class="table-wrap"><table><caption id="listTitle" class="sr-only">浏览器实例列表</caption><thead><tr><th scope="col">实例名称</th><th scope="col">浏览器类型</th><th scope="col">用户数据目录</th><th scope="col">调试端口</th><th scope="col">状态</th><th scope="col">操作</th></tr></thead><tbody id="instanceRows"></tbody></table><div class="empty-state" id="emptyState"><strong>没有匹配的实例</strong><span>尝试清除筛选,或新建一个浏览器实例。</span></div></div>
<div class="table-footer"><span id="resultCount">显示 4 个实例</span><span>最后检查:刚刚</span></div>
</section>
@@ -266,6 +273,8 @@
<div class="drawer-backdrop" id="backdrop"></div>
<aside class="drawer" id="drawer" aria-label="实例详情和配置" aria-hidden="true"></aside>
<dialog id="confirmDialog" aria-labelledby="dialogTitle"><div class="dialog-inner"><h2 id="dialogTitle">关闭浏览器实例?</h2><p id="dialogText">将请求浏览器正常退出。未保存的网页内容由浏览器自行处理。</p><div class="dialog-warning" id="dialogWarning" hidden>强制终止可能造成 profile 数据损坏。仅在浏览器无响应时使用。</div><div class="dialog-actions"><button class="secondary" id="dialogCancel">取消</button><button class="primary" id="dialogConfirm">优雅关闭</button></div></div></dialog>
<dialog class="edit-dialog" id="editDialog" aria-labelledby="editTitle"><form class="dialog-inner edit-form" id="editForm"><div><h2 id="editTitle">编辑实例</h2><p id="editSubtitle">修改下次启动使用的保存配置。</p></div><div class="field"><label for="editName">实例名称 <span>*</span></label><input id="editName" required><small>用于在实例列表中识别此浏览器环境。</small></div><div class="field"><span>浏览器类型</span><div class="radio-row" role="radiogroup" aria-label="浏览器类型"><label><input type="radio" name="editKind" value="chrome" id="editChrome"> Chrome</label><label><input type="radio" name="editKind" value="edge" id="editEdge"> Edge</label></div></div><div class="field"><label for="editDataDir">User Data Dir <span>*</span></label><div class="path-field"><input id="editDataDir" required><button type="button" class="secondary" id="chooseEditDir">选择目录</button></div><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="editURL">启动 URL(可选)</label><input id="editURL" type="url" placeholder="https://example.com"><small>仅支持 http/https;留空时启动浏览器默认页。</small></div><div class="field"><label for="editPort">当前调试端口</label><input class="readonly-value" id="editPort" readonly><small>实际端口由全局起始端口在启动时分配,不能在此固定。</small></div><div class="form-note" id="editReadOnlyNote" hidden>该实例正在运行或为外部关联。为避免配置与运行身份不一致,浏览器类型和 User Data Dir 已锁定。</div><div class="dialog-actions"><button type="button" class="secondary" id="editCancel">取消</button><button type="submit" class="primary" id="editSave">保存更改</button></div></form></dialog>
<dialog 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>
@@ -277,34 +286,68 @@
];
let selectedId = 'inst-chrome-shop';
let modalAction = 'stop';
let editingID = '';
let editSnapshot = null;
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.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.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="start" aria-label="${x.status === 'associated' ? '重新检测' : '启动'} ${x.name}">${x.status === 'associated' ? '重新检测' : '启动'}</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'); }));
<td><div class="row-actions"><button data-action="edit" aria-label="编辑 ${x.name}">编辑</button><button data-action="start" aria-label="${x.status === 'associated' ? '重新检测' : '启动'} ${x.name}">${x.status === 'associated' ? '重新检测' : '启动'}</button><button data-action="delete" aria-label="删除 ${x.name}">删除</button></div></td>`;
tr.addEventListener('click', e => { if (!e.target.closest('button')) selectRow(x.id, body); });
tr.addEventListener('dblclick', e => { if (!e.target.closest('button')) openEdit(x.id); });
tr.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); openEdit(x.id); } else if (e.key === ' ') { e.preventDefault(); selectRow(x.id, body); } });
tr.querySelectorAll('button').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); selectRow(x.id, body); if (b.dataset.action === 'edit') openEdit(x.id); else if (b.dataset.action === 'delete') showToast(`原型演示:将确认删除“${x.name}”的 Chub 配置,不会删除 User Data Dir。`); else openConfirm(x.id, 'start'); }));
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 || document.querySelector('input[name="editKind"]:checked')?.value !== editSnapshot.kind;
}
function focusEditedRow(id) { requestAnimationFrame(() => document.querySelector(`tr[data-id="${id}"]`)?.focus()); }
function finishEdit() { const id = editingID; $('editDialog').close(); editingID = ''; editSnapshot = null; focusEditedRow(id); }
function openEdit(id) {
const x = instances.find(i => i.id === id); if (!x) return;
selectedId = id; editingID = id; editSnapshot = { name: x.name, dir: x.dir, url: x.url || '', kind: x.kind };
const locked = editIsLocked(x); $('editName').value = x.name; $('editDataDir').value = x.dir; $('editURL').value = x.url || ''; $('editPort').value = x.port || '—'; $('editChrome').checked = x.kind === 'chrome'; $('editEdge').checked = x.kind === 'edge';
$('editSubtitle').textContent = locked ? '当前运行状态下可查看配置,但身份约束字段不可修改。' : '修改下次启动使用的保存配置。';
$('editChrome').disabled = locked; $('editEdge').disabled = locked; $('editDataDir').disabled = locked; $('chooseEditDir').disabled = locked; $('editReadOnlyNote').hidden = !locked;
$('editDialog').showModal(); setTimeout(() => $('editName').focus(), 0);
}
function saveEdit() {
const x = instances.find(i => i.id === editingID); if (!x) return finishEdit();
const name = $('editName').value.trim(), dir = $('editDataDir').value.trim(), url = $('editURL').value.trim();
if (!name || !dir) { showToast('请填写实例名称和 User Data Dir。'); (!name ? $('editName') : $('editDataDir')).focus(); return; }
const locked = editIsLocked(x); x.name = name; x.url = url;
if (!locked) { x.dir = dir; x.kind = document.querySelector('input[name="editKind"]:checked')?.value || x.kind; x.browser = x.kind === 'edge' ? 'Edge' : 'Chrome'; }
if ($('discardDialog').open) $('discardDialog').close(); renderRows(); const savedName = x.name; finishEdit(); showToast(`已保存“${savedName}”的实例配置。`);
}
function requestEditClose() { if (editIsDirty()) { $('editDialog').close(); $('discardDialog').showModal(); return; } finishEdit(); }
function openLaunch() { $('drawer').innerHTML = `<div class="drawer-head"><div><h2>新建浏览器实例</h2><p>配置完成后,Chub 会在本机启动一个隔离环境。</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭新建实例">×</button></div><form class="drawer-body" id="launchForm"><div class="form-section"><h3>浏览器配置</h3><div class="field"><span>浏览器类型 <span>*</span></span><div class="radio-row" role="radiogroup" aria-label="浏览器类型"><label><input type="radio" name="kind" value="chrome" checked> Chrome</label><label><input type="radio" name="kind" value="edge"> Edge</label></div></div><div class="field"><label for="exe">可执行文件</label><input id="exe" value="自动发现" aria-describedby="exeHelp"><small id="exeHelp">留空时按浏览器类型搜索标准安装路径。</small></div></div><div class="form-section"><h3>运行环境</h3><div class="field"><label for="dataDir">User Data Dir <span>*</span></label><div class="path-field"><input id="dataDir" placeholder="例如:D:\\Chub\\profiles\\new-instance" required><button type="button" class="secondary" id="chooseInstanceDir">选择目录</button></div><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="profile">profile-directory</label><input id="profile" value="Default"></div><div class="field"><label for="url">启动 URL(可选)</label><input id="url" type="url" placeholder="https://example.com"><small>仅支持 http/https;留空时启动浏览器默认页。</small></div><div class="field"><label for="proxy">代理服务器</label><input id="proxy" placeholder="可选,例如 http://127.0.0.1:8080"><small>仅支持无认证代理,认证信息不会被保存。</small></div></div><div class="form-section"><h3>启动选项</h3><label class="check-line"><input type="checkbox" id="headless"> <span><b>无头模式</b><br><small class="help">启动后台浏览器,不显示窗口。</small></span></label><label class="check-line"><input type="checkbox" id="remember" checked> <span><b>保存此配置</b><br><small class="help">只保存路径和非敏感参数。</small></span></label></div><div class="drawer-actions"><button type="button" class="secondary" id="cancelLaunch">取消</button><button type="submit" class="primary">启动实例</button></div></form>`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('cancelLaunch').onclick = hideDrawer; $('chooseInstanceDir').onclick = () => showToast('原型演示:将打开目录选择器。'); $('launchForm').onsubmit = e => { e.preventDefault(); const data = $('dataDir'); if (!data.value.trim()) { data.focus(); data.parentElement.classList.add('error'); const msg = document.createElement('div'); msg.className = 'field-error'; msg.textContent = '请填写 User Data Dir。'; data.parentElement.appendChild(msg); return; } hideDrawer(); setTimeout(() => showToast('启动请求已提交,实例进入 STARTING 状态。'), 80); }; }
function 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 ? '切换深色主题' : '切换浅色主题'); };
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)); });