chore: establish chub go foundation

This commit is contained in:
QiuSW
2026-07-22 14:51:11 +08:00
commit f8aaaeda21
28 changed files with 886 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# AI 开发入口
Chub 是 Windows 本地 Chrome/Edge 实例管理工具。第一版只做:配置并启动浏览器实例、查看工具管理的实例、检测 profile 占用、优雅关闭/强制关闭/重启和状态事件。
## 必读顺序
1. 根目录 `AGENTS.md`
2. [`01-vision.md`](01-vision.md)
3. [`02-requirements.md`](02-requirements.md)
4. [`03-tech-stack.md`](03-tech-stack.md)
5. [`04-architecture.md`](04-architecture.md)
6. [`05-coding-rules.md`](05-coding-rules.md)
7. 与任务相关的 [`api.md`](api.md)、[`07-user-stories.md`](07-user-stories.md) 和 [`08-interaction-checklist.md`](08-interaction-checklist.md)
8. [`06-tasks.md`](06-tasks.md)、任务文件和 [`current-state.md`](current-state.md)
## 固定开工流程
1. 确认当前目录为 `D:\OPC\chub`。
2. 检查 Git 状态和最近提交,不覆盖已有未提交改动。
3. 读取当前状态,确认基线和 blocker。
4. 领取一个依赖已完成的 TODO 任务,编辑任务状态为 DOING。
5. 完成代码、测试和任务中列出的 Windows smoke 后再标记 DONE。
6. 将验证结果写入任务文件,并更新 `current-state.md`。
## 关键边界
- 只管理本工具启动并注册的实例;外部实例默认只检测,不接管。
- 禁止执行 `taskkill /IM chrome.exe`、`/IM msedge.exe` 或等价的全局终止。
- 不把密码、Cookie、Token 或代理认证信息写入配置、日志、命令行或事件。
- UI 不执行 I/O;浏览器启动、进程查询和关闭均在后台任务中完成。
+21
View File
@@ -0,0 +1,21 @@
# 产品愿景
Chub 为 Windows 用户提供一个可复用的浏览器实例控制层,将 Chrome/Edge 的可执行文件、`user-data-dir`、profile、启动参数和生命周期统一管理。
## 用户
- 需要同时运行多个隔离浏览器环境的运营和测试人员。
- 需要脚本可靠启动和回收浏览器的开发人员。
- 需要桌面化观察浏览器状态的维护人员。
## 原则
1. 实例身份优先:PID、命令行、浏览器类型和 user data dir 必须能互相校验。
2. 安全关闭优先:先优雅关闭,明确确认后才强制终止。
3. 外部进程不误伤:不按进程名全局操作。
4. 参数透明:展示实际启动参数,但隐藏敏感信息。
5. 本地优先:MVP 不需要云服务和平台账号。
## 非目标
首版不保存凭证、不自动登录、不绕过验证码、不做反指纹/防关联、不做平台 RPA、不承诺接管任意外部浏览器、不做远程主机进程管理。
+29
View File
@@ -0,0 +1,29 @@
# 产品需求
| ID | 需求 | 优先级 |
| --- | --- | --- |
| BR-001 | 支持 Chrome 和 Edge,允许自动发现或手动指定 executable。 | P0 |
| BR-002 | 启动配置支持 `user-data-dir`、`profile-directory`、URL、代理、headless 和额外参数。 | P0 |
| BR-003 | 启动前检测目标 user data dir 是否占用,禁止同一环境重复启动。 | P0 |
| BR-004 | 实例列表显示浏览器类型、PID、user data dir、启动时间和状态。 | P0 |
| BR-005 | 支持优雅关闭、强制关闭和重启,并明确操作影响。 | P0 |
| BR-006 | 监控启动失败、异常退出、退出码和进程消失。 | P0 |
| BR-007 | 启动参数按数组传递,不通过 shell 拼接。 | P0 |
| BR-008 | 只允许管理工具注册且经过身份校验的 PID。 | P0 |
| BR-009 | 支持本地配置持久化,绝不保存浏览器凭证。 | P1 |
| BR-010 | 提供 CLI 或本地 API,供脚本启动、查询和关闭实例。 | P1 |
| BR-011 | 可选启用本地 CDP,用于读取窗口/tab 信息;默认关闭。 | P2 |
## 验收标准
- 两个不同 user data dir 可以并行启动,互不阻塞。
- 同一 user data dir 的第二次启动被拒绝,并显示已知 PID/来源。
- 关闭操作不会影响其他 Chrome/Edge 实例。
- 工具重启后,无法确认身份的外部进程只能报告 UNKNOWN,不得被关闭。
- 异常退出后状态在合理时间内变为 EXITED/FAILED。
- 参数中不出现密码、Cookie、Token 或代理 userinfo。
- UI 覆盖加载、空、占用、启动失败、关闭中、成功和权限错误。
## 不做
全局杀 Chrome/Edge、自动登录、页面操作、验证码处理、平台 API 集成、远程控制和跨用户权限提升。
+23
View File
@@ -0,0 +1,23 @@
# 技术栈与验证
| 层 | 选择 | 约束 |
| --- | --- | --- |
| 核心 | Go 1.23(当前基线) | 标准库优先,Windows API 通过小型适配层封装;升级到 Go 1.25+ 需重新验证构建。 |
| Windows UI | Go Gio(版本待锁定) | UI 线程不执行进程和文件 I/O;实现前制作 HTML 原型。 |
| 持久化 | SQLite 或 JSON(MVP 前决策) | 配置不保存密码、Cookie、Token。 |
| 进程 | `os/exec` + Windows API | 参数数组启动;Job Object、窗口消息和句柄按职责封装。 |
| API | 本地 CLI,后续可选 loopback HTTP/WebSocket | 默认只监听本机。 |
## 验证命令
建立代码后同步并执行:
```powershell
go test ./...
go vet ./...
go build ./cmd/chub
```
Windows smoke 至少覆盖真实 Chrome/Edge 临时 user data dir、双实例、重复占用、优雅关闭、强制关闭和异常退出。
新增依赖必须说明版本、许可证、Windows 兼容性和打包影响。核心启动和身份校验优先使用标准库和 Win32 API。
+41
View File
@@ -0,0 +1,41 @@
# 架构设计
```text
Gio UI / CLI
↓ DTO + commands
Application BrowserManager
↓ ports
Platform Windows
├─ executable discovery
├─ launcher
├─ process identity / query
├─ graceful close / force kill
├─ Job Object
└─ profile occupancy inspector
↓
Chrome / Edge processes
```
UI/CLI 不直接操作 `exec.Cmd`、Win32 handle 或数据库。Application 层维护实例状态和授权边界;Platform 层只提供系统能力。
## 实例身份
每个实例至少记录 `instance_id`、browser kind、executable、root PID、user data dir、profile directory、非敏感参数摘要、创建时间、状态和退出码。
进程身份校验至少比较 PID 存活、executable、命令行中的规范化 `--user-data-dir` 和注册记录。无法确认身份时只能报告 UNKNOWN,不允许关闭。
## 生命周期
`CREATED -> STARTING -> RUNNING -> STOPPING -> EXITED`;启动失败为 `FAILED`,无法确认的外部实例为 `UNKNOWN`。
- Start:规范化路径和参数,检查 user data dir,占用检查通过后注册 pending,再启动。
- Stop:校验身份,优先窗口关闭/浏览器协议,超时后经明确授权强制终止。
- Force stop:使用实例 Job Object 或已校验进程树,不按名称全局终止。
- Restart:旧实例退出或完成超时处理后重新执行同一配置。
- App shutdown:取消监控,不默认终止用户浏览器。
Chrome 和 Edge 共用大部分 Chromium 参数,但 executable 默认路径、进程名称和安装方式不同,通过 `BrowserDefinition` 封装差异。
## 风险
Chromium 可能复用已有进程;浏览器是多进程架构;管理员权限进程可能无法查询;强制终止可能损坏 profile;CDP 端口开放到非 loopback 会形成控制风险,MVP 默认关闭。
+14
View File
@@ -0,0 +1,14 @@
# 编码规则
1. 启动参数使用 `exec.Command(executable, args...)`,不拼 shell 字符串。
2. 关闭前必须校验 PID、executable 和 user data dir;无法确认则拒绝。
3. 禁止 `taskkill /IM chrome.exe`、`/IM msedge.exe` 或等效全局终止。
4. 默认不接管外部实例;接管能力必须新增需求、安全评审和测试。
5. 日志、错误、事件和持久化不得包含密码、Cookie、Token、代理认证或完整秘密参数。
6. 用户路径必须规范化并有绝对路径、权限和 Windows 长路径测试。
7. I/O 方法接收 `context.Context`,取消管理任务不能隐式杀死浏览器。
8. 每个实例最多一个监控 goroutine;退出后释放 registry 和句柄。
9. 不在 UI frame 或事件回调中执行阻塞 I/O;共享状态必须有明确所有权。
10. 错误使用稳定 code 或 typed error,底层 cause 只进入诊断日志。
11. UI 的加载、空、失败、确认和恢复路径必须写入交互清单。
12. API 使用 DTO,不暴露平台句柄、SQL 行或原始系统堆栈。
+33
View File
@@ -0,0 +1,33 @@
# 任务路线图
状态:TODO、DOING、DONE、BLOCKED。未启用协作服务时以 `docs/tasks/T-<编号>.md` 为单任务权威。
## Phase 0:地基
| ID | 任务 | 依赖 | 状态 |
| --- | --- | --- | --- |
| T-001 | 建立 Go module、命令入口、日志和测试基座 | - | TODO |
| T-002 | 固化 BrowserDefinition、LaunchSpec、错误和事件合约 | T-001 | TODO |
| T-003 | 实现可测试的参数构造和 Chrome/Edge 发现 | T-002 | TODO |
## Phase 1:进程核心
| ID | 任务 | 依赖 | 状态 |
| --- | --- | --- | --- |
| T-101 | 实现启动、registry、Wait 和 profile 占用检测 | T-003 | TODO |
| T-102 | 实现 Windows 进程身份查询和外部实例保守识别 | T-101 | TODO |
| T-103 | 实现优雅关闭、强制关闭和 Job Object 进程树 | T-102 | TODO |
| T-104 | Chrome/Edge 双实例、异常退出和权限失败 smoke | T-103 | TODO |
## Phase 2:产品外壳
| ID | 任务 | 依赖 | 状态 |
| --- | --- | --- | --- |
| T-201 | HTML 原型、Gio 外壳和实例列表/表单 | T-002 | TODO |
| T-202 | 本地配置持久化和实例恢复 | T-101 | TODO |
| T-203 | CLI 合约和本地事件推送 | T-103 | TODO |
| T-204 | UI 全状态验收与 Windows 打包 smoke | T-201,T-202,T-203 | TODO |
## Backlog
CDP tab/window 管理、导入导出启动配置、进程资源监控、多用户权限和远程主机管理。
+24
View File
@@ -0,0 +1,24 @@
# 用户故事
| ID | 用户故事 | 优先级 |
| --- | --- | --- |
| US-001 | 用户可以创建 Chrome/Edge 实例配置,并指定 user data dir、profile 和 URL。 | P0 |
| US-002 | 用户可以启动两个不同隔离环境,并看到各自 PID 和运行状态。 | P0 |
| US-003 | 用户可以发现同一 user data dir 已被占用,避免重复启动。 | P0 |
| US-004 | 用户可以优雅关闭实例;无响应时在确认后强制终止。 | P0 |
| US-005 | 用户可以重启指定实例,并确认旧实例已经退出。 | P0 |
| US-006 | 用户可以通过 CLI/API 查询、启动和关闭实例。 | P1 |
## 验收场景
### US-003 占用保护
给定一个已运行的 Chrome/Edge,使用相同规范化 user data dir 启动时,系统拒绝第二次启动,显示已知 PID 和占用来源,并保留原配置。
### US-004 安全关闭
用户点击关闭后按钮进入 STOPPING;浏览器正常退出则显示 EXITED;等待超时后只显示强制终止确认,不自动扩大关闭范围。
### US-006 CLI/API
CLI/API 返回稳定错误码和结构化数据;未知 PID、身份不匹配和权限不足必须拒绝操作并给出恢复建议。
+18
View File
@@ -0,0 +1,18 @@
# 交互清单
| ID | 页面/组件 | 交互 | 状态覆盖 | 优先级 |
| --- | --- | --- | --- | --- |
| IX-001 | 实例列表 | 新建、启动、刷新、查看详情 | 加载、空、运行、退出、失败 | P0 |
| IX-002 | 启动表单 | 浏览器类型、路径、user data dir、URL、参数校验 | 默认、校验错误、提交中、成功 | P0 |
| IX-003 | 实例详情 | 优雅关闭、强制终止、重启 | 运行中、关闭中、超时确认、权限错误 | P0 |
| IX-004 | 占用提示 | 查看 PID/来源并重试 | 被占用、身份未知、已释放 | P0 |
| IX-005 | 设置 | 默认路径、日志和数据目录 | 保存成功、路径无效、权限拒绝 | P1 |
## 通用规则
- 异步操作不阻塞窗口线程,提交中禁用重复按钮。
- 危险操作说明影响范围,默认焦点在取消;Escape 可取消。
- 进程状态同时使用文字、图标和颜色,不只用颜色表达。
- 关闭完成后焦点回到实例行;失败时保留表单和用户输入。
- 验证 960x640、600x500、100%–200% 缩放、键盘、深色和高对比度回退。
- 不依赖悬停或触控手势完成关键任务。
+21
View File
@@ -0,0 +1,21 @@
# 项目文档导航
Chub 是 Windows 本地 Chrome/Edge 实例管理工具,首版闭环是“配置实例 -> 启动 -> 查看状态 -> 关闭/重启”。
| 文档 | 职责 |
| --- | --- |
| [`00-ai-start-here.md`](00-ai-start-here.md) | agent 阅读顺序和开工流程 |
| [`01-vision.md`](01-vision.md) | 产品目标、用户和非目标 |
| [`02-requirements.md`](02-requirements.md) | 功能范围、优先级和验收 |
| [`03-tech-stack.md`](03-tech-stack.md) | 技术选型和验证命令 |
| [`04-architecture.md`](04-architecture.md) | 分层、模块、进程模型和数据流 |
| [`05-coding-rules.md`](05-coding-rules.md) | 安全、Windows、Go 和 UI 规则 |
| [`06-tasks.md`](06-tasks.md) | 阶段路线图和任务池 |
| [`07-user-stories.md`](07-user-stories.md) | 用户故事与验收场景 |
| [`08-interaction-checklist.md`](08-interaction-checklist.md) | 页面状态、交互和无障碍 |
| [`api.md`](api.md) | 本地模块、CLI、事件和错误合约 |
| [`current-state.md`](current-state.md) | 当前代码现实和下一步 |
| [`agent-context.json`](agent-context.json) | 机器可读上下文路由 |
| [`design/README.md`](design/README.md) | HTML 原型输入和交接规则 |
| [`ui/README.md`](ui/README.md) | Chub UI 原型、评审场景和 Gio 交接 |
| [`tasks/README.md`](tasks/README.md) | 一任务一文件约定 |
+12
View File
@@ -0,0 +1,12 @@
{
"schema":"docs/agent-context.schema.json",
"schema_version":1,
"bootstrap":["AGENTS.md","docs/00-ai-start-here.md","docs/05-coding-rules.md","docs/current-state.md"],
"routes":{
"documentation":["README.md","docs/README.md","docs/01-vision.md","docs/02-requirements.md","docs/07-user-stories.md","docs/08-interaction-checklist.md"],
"browser":["docs/02-requirements.md","docs/03-tech-stack.md","docs/04-architecture.md","docs/05-coding-rules.md","docs/api.md"],
"ui":["docs/07-user-stories.md","docs/08-interaction-checklist.md","docs/ui/README.md","docs/04-architecture.md"],
"deploy":["docs/03-tech-stack.md","docs/current-state.md"]
},
"tasks":{"roadmap":"docs/06-tasks.md","directory":"docs/tasks/","template":"docs/tasks/_template.md"}
}
+7
View File
@@ -0,0 +1,7 @@
# Agent 上下文清单
`agent-context.json` 是按任务类型选择文档的机器可读清单,Schema 位于 `agent-context.schema.json`。
首次接入读取 bootstrap;日常任务再读取对应 route 和唯一任务文件。需求以 `02-requirements.md` 为准,架构/API 以 `04-architecture.md` 与 `api.md` 为准,代码行为以测试和真实 Windows smoke 为准。
本项目未启用 Gitea 协调。任务状态以 `docs/tasks/T-<编号>.md` 的 frontmatter 和 `06-tasks.md` 为准;外部状态不可用时可以继续已领取任务,但不得猜测其他 agent 的写路径。
+12
View File
@@ -0,0 +1,12 @@
{
"$schema":"https://json-schema.org/draft/2020-12/schema",
"type":"object",
"required":["schema","schema_version","bootstrap","routes","tasks"],
"properties":{
"schema":{"const":"docs/agent-context.schema.json"},
"schema_version":{"const":1},
"bootstrap":{"type":"array","items":{"type":"string"},"minItems":1},
"routes":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}},
"tasks":{"type":"object","required":["roadmap","directory","template"]}
}
}
+68
View File
@@ -0,0 +1,68 @@
# API / 模块合约
MVP 首先提供本地 Go application service 和 CLI;是否增加 loopback HTTP/WebSocket 在 T-203 决策。时间使用 RFC3339,路径使用规范化绝对路径。
## 核心类型
```go
type BrowserKind string
const (
BrowserChrome BrowserKind = "chrome"
BrowserEdge BrowserKind = "edge"
)
type LaunchSpec struct {
Kind BrowserKind
Executable string
UserDataDir string
ProfileDirectory string
TargetURL string
ProxyServer string
Headless bool
ExtraArgs []string
}
type InstanceView struct {
ID string
Kind BrowserKind
PID int
UserDataDir string
Status string
ExitCode *int
}
```
## Application service
```go
type BrowserManager interface {
Start(ctx context.Context, spec LaunchSpec) (InstanceView, error)
List(ctx context.Context) ([]InstanceView, error)
Get(ctx context.Context, id string) (InstanceView, error)
Stop(ctx context.Context, id string, force bool) error
Restart(ctx context.Context, id string) (InstanceView, error)
}
```
`Stop(force=false)` 只执行优雅关闭;强制关闭必须由明确用户动作或 CLI `--force` 触发。
## CLI 目标形状
```text
chub start --browser chrome --user-data-dir <dir> [--profile-directory <name>] [--url <url>]
chub list
chub stop <instance-id> [--force]
chub restart <instance-id>
```
退出码:0 成功,2 参数错误,3 profile 占用,4 实例不存在,5 身份校验失败,6 系统权限/进程错误。
## 事件
| 事件 | 触发 | 负载 |
| --- | --- | --- |
| `browser.started` | 新实例完成启动 | InstanceView |
| `browser.status.changed` | 状态变化 | instance_id、status、pid、error_code |
| `browser.exited` | 进程退出 | instance_id、exit_code |
事件禁止携带密码、Cookie、Token、完整代理认证信息或原始系统堆栈。
+34
View File
@@ -0,0 +1,34 @@
# 当前实现状态
## 快照
- 日期:2026-07-22
- 阶段:文档与交互原型准备
- 代码:已建立 Go module `chub`、`cmd/chub` 入口和 logging 测试基座,T-001 已完成
- UI:尚未实现;P0 页面需要先制作 HTML 原型
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
- blocker:无;下一个任务为 T-002
## 当前目录
| 路径 | 状态 | 说明 |
| --- | --- | --- |
| `docs/` | 已建立 | Harness Coding 项目文档 |
| `docs/ui/` | 已建立 | Chub HTML 交互原型 |
| `docs/tasks/` | 已建立 | 一任务一文件目录 |
| `cmd/` | 待建 | Go 命令入口 |
| `cmd/chub/` | 已建立 | Go 命令入口和版本输出 |
| `internal/platform/logging/` | 已建立 | 可注入的结构化 JSON 日志 |
| `internal/` | 部分建立 | 后续扩展 application/domain/platform 实现 |
## 基线命令
当前已具备最小可运行入口:
```text
go test ./...
go vet ./...
go build -o build/chub.exe ./cmd/chub
```
`shop_helm` 仅是参考项目,不是本项目代码目录。当前基线已通过 T-001 的测试、vet、构建和启动验证。
+5
View File
@@ -0,0 +1,5 @@
# 设计原型输入约定
P0 UI 在正式 Gio 实现前制作单文件 HTML 原型。原型只用假数据,标注“PROTOTYPE - 仅供交互确认”,不连接真实进程、配置或浏览器。
原型用于枚举页面控件;加载、空、错误、确认、取消、键盘和无障碍行为以 `08-interaction-checklist.md` 为准。原型确认后,将控件和状态映射到 IX 条目,再按 `04-architecture.md` 实现,禁止逐字复制 HTML 到生产代码。
+3
View File
@@ -0,0 +1,3 @@
# 一任务一文件
任务文件使用 `T-<编号>.md`,包含 frontmatter:`id`、`title`、`phase`、`deps`、`status`、`created`、`owner`。每轮只领取一个依赖已完成的 TODO;验证完成后标记 DONE,并在任务文件记录变更、命令、结果和残余风险。
+34
View File
@@ -0,0 +1,34 @@
---
id: T-001
title: 建立 Go module、命令入口、日志和测试基座
phase: 0
deps: []
status: DONE
created: 2026-07-22
owner: codex
---
## 需求与背景
Chub 目前只有产品、架构和 UI 原型文档,需要建立可编译、可测试、可继续扩展的 Go 工程基线。
## 方案与边界
- 建立 module `chub` 和 `cmd/chub` 命令入口。
- 增加可注入输出的结构化 JSON logger 和单元测试。
- 固定当前可验证的 Go 1.23 基线;UI、浏览器进程和持久化不在本任务实现。
## 验收要点
- `go test ./...` 通过。
- `go vet ./...` 通过。
- `go run ./cmd/chub` 输出版本并正常退出。
- `go build -o build/chub.exe ./cmd/chub` 通过。
## 执行记录
- 状态:DONE
- 变更:建立 `chub` Go module、`cmd/chub` 入口、可注入 JSON logger、logger 单元测试和 `.gitignore`;基线固定为 Go 1.23。
- 验证:`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、`go run ./cmd/chub` 均通过。
- 阻塞:
- 残余风险:
+27
View File
@@ -0,0 +1,27 @@
---
id: T-XXX
title: 一句话任务名
phase: 0
deps: []
status: TODO
created: 2026-07-22
owner:
---
## 需求与背景
## 方案与边界
## 验收要点
- 自动化命令:
- Windows smoke:
- 安全边界:
## 执行记录
- 状态:
- 变更:
- 验证:
- 阻塞:
- 残余风险:
+38
View File
@@ -0,0 +1,38 @@
# Chub UI 原型
原型文件:[`browser-manager.html`](browser-manager.html)
## 用途
该文件用于确认 Chub 的信息架构、实例列表密度、启动配置表单、详情抽屉和危险操作反馈,不代表 Gio 的最终控件、窗口材质、DPI 或无障碍实现已经完成。
## 评审场景
1. 默认实例列表:运行中、启动中、被占用、已退出四种状态同时可见。
2. 点击“新建实例”:检查浏览器类型、executable、user data dir、profile、URL、代理和无头模式字段。
3. 留空 user data dir 提交:观察字段级错误和输入保留。
4. 点击运行中实例:查看 PID、路径、启动地址和操作入口。
5. 点击“关闭实例”:确认对话框默认提供优雅关闭;重启需要先等待旧实例退出。
6. 使用搜索、浏览器筛选、状态筛选和刷新状态。
7. 切换深色主题,并在窄窗口查看导航和表单重排。
8. 点击“设置”:检查 Chrome/Edge 路径、默认 user data dir、日志目录、运行行为,以及保存/取消反馈。
## 当前原型假设
- 顶级导航只有“实例、设置”;活动不再是独立模块,作为实例详情中的“最近活动”时间线显示。
- 详情和新建配置使用右侧抽屉,不创建文档标签。
- 选择实例后,右侧详情同时承载当前状态、操作、配置摘要和最近活动;不要求用户在实例与活动页面之间切换。
- 设置作为独立顶级页面,采用显式提交;取消只恢复本次未保存的表单修改。
- 外部实例显示占用证据,但不提供接管或强制关闭入口。
- 原型中的“确认”只模拟事件反馈,不启动真实浏览器或改变文件。
## Gio 交接方向
| 原型元素 | Gio 适配意图 |
| --- | --- |
| 顶级导航 | 稳定 page ID + 持久选中状态 |
| 实例列表 | 虚拟化列表;实例 ID 而非行号作为身份 |
| 启动表单 | 持久 editor/选择状态,显式提交,字段错误保留输入 |
| 详情抽屉 | 自定义辅助面板,关闭后焦点返回实例行 |
| 确认对话框 | 模态层限制背景交互,Escape 取消,危险操作不默认聚焦 |
| Toast/状态区 | 非模态状态反馈;异步结果通过统一事件触发重绘 |
+312
View File
@@ -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 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; }
.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.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::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; }
.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; gap: 8px; }
.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; }
.top-actions .primary { flex: 1; }
.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><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>