+ {/* ─── 标签条 + 工具条(compact 合并为一行 / 两行 Chrome 标准)─── */}
+ {compact ? (
+
+ {/* tabs 区:限宽 + 横向滚动,多 tab 不挤掉地址栏 */}
+
+ {tabsStrip}
+
+ {/* 工具条区:地址栏 flex:1 占余 */}
+
+ {toolbarControls}
+
+
+ ) : (
+ <>
+
+ {tabsStrip}
+
+
+ {toolbarControls}
+
+ >
+ )}
+
+ {/* ─── 内容区 ─── */}
+ {currentUrl ? (
+
+ );
+}
+
+// ─── 新标签页(空状态) ─────────────────────────────────────────
+
+function NewTabPage({ bookmarks, history, onNavigate, onOpenBookmarks }) {
+ const [val, setVal] = useState('');
+ const submit = () => { const u = normalizeUrl(val); if (u) onNavigate(u); };
+ const recent = (history || []).slice(0, 8);
+ return (
+
+
+
+ 浏览器
+
+
+
+ setVal(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') submit(); }}
+ placeholder="输入网址(如 192.168.1.10:3000 或 example.com)"
+ spellCheck={false}
+ style={{ flex: 1, border: 'none', background: 'transparent', outline: 'none', fontSize: 14, color: T.ink, ...MONO }}
+ />
+
+
+ {(bookmarks && bookmarks.length > 0) && (
+
+
书签
+
+ {bookmarks.slice(0, 8).map(b => (
+
+ ))}
+
+
+ )}
+
+ {recent.length > 0 && (
+
+
最近访问
+ {recent.map((h, i) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ─── 书签 / 历史 下拉面板 ───────────────────────────────────────
+
+function BrowserPanel({ title, items, emptyHint, onOpen, onDelete, onClear, onClose }) {
+ return (
+ <>
+
+
+
{title}
+
+ {onClear && items.length > 0 && (
+
+ )}
+
+ {items.length === 0 ? (
+
{emptyHint}
+ ) : items.map(it => (
+
onOpen(it)}>
+
+
+
{it.title}
+
{it.url}
+
+ {onDelete && (
+
+ )}
+
+ ))}
+
+ >
+ );
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index c6a986f..c1e2e0f 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -31,6 +31,8 @@ type ConsoleConfig struct {
SupervisorSocket string `mapstructure:"supervisor_socket"` // supervisord Unix socket 路径
SupervisorConfDir string `mapstructure:"supervisor_conf_dir"` // supervisor conf.d 目录
LinksPath string `mapstructure:"links_path"` // 服务导航 YAML 路径;空 = /etc/devbox/links.yaml
+ BrowserDataPath string `mapstructure:"browser_data_path"` // 浏览器书签/历史 JSON 路径;空 = /etc/devbox/browser.json
+ BrowserInsecureTLS bool `mapstructure:"browser_insecure_tls"` // 浏览器代理是否跳过远端 TLS 校验(内网自签证书)
}
// KubernetesConfig 可选的 K8s 集成(应用市场 / Pod 管理)。
@@ -75,6 +77,8 @@ func Load(configFile string) (*Config, error) {
v.BindEnv("console.console_url", "DEVBOX_CONSOLE_URL")
v.BindEnv("console.supervisor_socket", "DEVBOX_SUPERVISOR_SOCKET")
v.BindEnv("console.supervisor_conf_dir", "DEVBOX_SUPERVISOR_CONF_DIR")
+ v.BindEnv("console.browser_data_path", "DEVBOX_CONSOLE_BROWSER_DATA_PATH")
+ v.BindEnv("console.browser_insecure_tls", "DEVBOX_CONSOLE_BROWSER_INSECURE_TLS")
v.BindEnv("kubernetes.kubeconfig", "DEVBOX_KUBECONFIG")
v.BindEnv("kubernetes.namespace", "DEVBOX_NAMESPACE")
v.BindEnv("kubernetes.apiserver_url", "DEVBOX_APISERVER_URL")
@@ -108,6 +112,8 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("console.supervisor_socket", "/var/run/supervisor.sock")
v.SetDefault("console.supervisor_conf_dir", "/etc/supervisor/conf.d")
v.SetDefault("console.links_path", "/etc/devbox/links.yaml")
+ v.SetDefault("console.browser_data_path", "/etc/devbox/browser.json")
+ v.SetDefault("console.browser_insecure_tls", false)
v.SetDefault("kubernetes.kubeconfig", "")
v.SetDefault("kubernetes.namespace", "default")
diff --git a/pkg/console/browser_store.go b/pkg/console/browser_store.go
new file mode 100644
index 0000000..2e370e6
--- /dev/null
+++ b/pkg/console/browser_store.go
@@ -0,0 +1,173 @@
+package console
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "sync"
+ "time"
+)
+
+// browserStore 浏览器应用的书签 / 历史持久化存储。
+// 数据源:单个 JSON 文件(默认 /etc/devbox/browser.json,路径由 Config.BrowserDataPath 覆盖)。
+// 并发范式仿 pkg/links:RWMutex + 返回深拷贝;区别在于这里需要可写,落盘走原子 rename。
+type browserStore struct {
+ path string
+ maxHistory int
+
+ mu sync.RWMutex
+ bookmarks []Bookmark
+ history []HistoryEntry
+}
+
+// Bookmark 一条书签。
+type Bookmark struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Icon string `json:"icon,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+// HistoryEntry 一条访问记录(最新在前)。
+type HistoryEntry struct {
+ URL string `json:"url"`
+ Title string `json:"title"`
+ VisitedAt time.Time `json:"visitedAt"`
+}
+
+type browserStoreDoc struct {
+ Bookmarks []Bookmark `json:"bookmarks"`
+ History []HistoryEntry `json:"history"`
+}
+
+// newBrowserStore 从指定路径加载书签/历史。path 空时用默认位置。
+// 文件不存在或解析失败时返回空 store,不报错(首次使用是常态)。
+func newBrowserStore(path string) *browserStore {
+ if path == "" {
+ path = "/etc/devbox/browser.json"
+ }
+ s := &browserStore{path: path, maxHistory: 200}
+ s.reload()
+ return s
+}
+
+func (s *browserStore) reload() {
+ b, err := os.ReadFile(s.path)
+ if err != nil {
+ return // 文件不存在:留空,首次写入时创建
+ }
+ var doc browserStoreDoc
+ if err := json.Unmarshal(b, &doc); err != nil {
+ return // 损坏文件:不覆盖内存,等下次写操作纠正
+ }
+ s.mu.Lock()
+ s.bookmarks = doc.Bookmarks
+ if s.bookmarks == nil {
+ s.bookmarks = []Bookmark{}
+ }
+ s.history = doc.History
+ if s.history == nil {
+ s.history = []HistoryEntry{}
+ }
+ s.mu.Unlock()
+}
+
+// saveLocked 把当前数据原子写入磁盘。调用方必须已持有(写)锁。
+func (s *browserStore) saveLocked() error {
+ doc := browserStoreDoc{Bookmarks: s.bookmarks, History: s.history}
+ b, err := json.MarshalIndent(doc, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp := s.path + ".tmp"
+ if err := os.WriteFile(tmp, b, 0600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, s.path)
+}
+
+// AddBookmark 新建书签,prepend 到列表头。返回新建的书签。
+func (s *browserStore) AddBookmark(title, url string) (Bookmark, error) {
+ bm := Bookmark{
+ ID: randID(),
+ Title: title,
+ URL: url,
+ CreatedAt: time.Now(),
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.bookmarks = append([]Bookmark{bm}, s.bookmarks...)
+ return bm, s.saveLocked()
+}
+
+// RemoveBookmark 删除指定 ID 的书签。不存在视为成功。
+func (s *browserStore) RemoveBookmark(id string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ next := s.bookmarks[:0]
+ for _, b := range s.bookmarks {
+ if b.ID != id {
+ next = append(next, b)
+ }
+ }
+ s.bookmarks = next
+ return s.saveLocked()
+}
+
+// ListBookmarks 返回书签的只读拷贝。
+func (s *browserStore) ListBookmarks() []Bookmark {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ out := make([]Bookmark, len(s.bookmarks))
+ copy(out, s.bookmarks)
+ return out
+}
+
+// AddHistory 记录一次访问:同 URL 去重并置顶 + 更新访问时间,超过 maxHistory 截断尾部。
+func (s *browserStore) AddHistory(url, title string) error {
+ entry := HistoryEntry{URL: url, Title: title, VisitedAt: time.Now()}
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ // 去重:移除已存在的同 URL 条目
+ next := make([]HistoryEntry, 0, len(s.history)+1)
+ for _, h := range s.history {
+ if h.URL != url {
+ next = append(next, h)
+ }
+ }
+ next = append([]HistoryEntry{entry}, next...)
+ if len(next) > s.maxHistory {
+ next = next[:s.maxHistory]
+ }
+ s.history = next
+ return s.saveLocked()
+}
+
+// ClearHistory 清空全部历史。
+func (s *browserStore) ClearHistory() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.history = []HistoryEntry{}
+ return s.saveLocked()
+}
+
+// ListHistory 返回历史记录的只读拷贝(最新在前)。
+func (s *browserStore) ListHistory() []HistoryEntry {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ out := make([]HistoryEntry, len(s.history))
+ copy(out, s.history)
+ return out
+}
+
+// randID 生成 8 字节随机 hex 作为书签 ID。
+func randID() string {
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ // rand.Read 极少失败;退化用时间戳兜底,保证非空
+ return hex.EncodeToString([]byte(time.Now().Format("20060102150405")))[:16]
+ }
+ return hex.EncodeToString(b[:])
+}
diff --git a/pkg/console/browser_store_test.go b/pkg/console/browser_store_test.go
new file mode 100644
index 0000000..d5b97f9
--- /dev/null
+++ b/pkg/console/browser_store_test.go
@@ -0,0 +1,107 @@
+package console
+
+import (
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+func tmpStore(t *testing.T) (*browserStore, string) {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "browser.json")
+ return newBrowserStore(path), path
+}
+
+func TestAddBookmarkAndList(t *testing.T) {
+ s, _ := tmpStore(t)
+ bm, err := s.AddBookmark("Site", "http://example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bm.ID == "" || bm.URL != "http://example.com" {
+ t.Errorf("unexpected bookmark: %+v", bm)
+ }
+ list := s.ListBookmarks()
+ if len(list) != 1 || list[0].URL != "http://example.com" {
+ t.Errorf("ListBookmarks = %+v", list)
+ }
+}
+
+func TestRemoveBookmark(t *testing.T) {
+ s, _ := tmpStore(t)
+ bm, _ := s.AddBookmark("A", "http://a.com")
+ s.AddBookmark("B", "http://b.com")
+ if err := s.RemoveBookmark(bm.ID); err != nil {
+ t.Fatal(err)
+ }
+ list := s.ListBookmarks()
+ if len(list) != 1 || list[0].URL != "http://b.com" {
+ t.Errorf("after remove: %+v", list)
+ }
+}
+
+func TestAddHistoryDedupAndOrder(t *testing.T) {
+ s, _ := tmpStore(t)
+ s.AddHistory("http://a.com", "A")
+ s.AddHistory("http://b.com", "B")
+ // 再次访问 a → 应置顶且只剩 2 条
+ s.AddHistory("http://a.com", "A2")
+ h := s.ListHistory()
+ if len(h) != 2 {
+ t.Fatalf("want 2 entries, got %d: %+v", len(h), h)
+ }
+ if h[0].URL != "http://a.com" || h[0].Title != "A2" {
+ t.Errorf("top should be a.com/A2, got %+v", h[0])
+ }
+ if h[1].URL != "http://b.com" {
+ t.Errorf("second should be b.com, got %+v", h[1])
+ }
+}
+
+func TestMaxHistoryTruncation(t *testing.T) {
+ s, _ := tmpStore(t)
+ s.maxHistory = 3
+ for i := 0; i < 5; i++ {
+ s.AddHistory("http://x"+string(rune('a'+i))+".com", "")
+ }
+ h := s.ListHistory()
+ if len(h) != 3 {
+ t.Fatalf("want 3 (truncated), got %d", len(h))
+ }
+}
+
+func TestClearHistory(t *testing.T) {
+ s, _ := tmpStore(t)
+ s.AddHistory("http://a.com", "A")
+ if err := s.ClearHistory(); err != nil {
+ t.Fatal(err)
+ }
+ if len(s.ListHistory()) != 0 {
+ t.Error("history not cleared")
+ }
+}
+
+func TestAtomicWriteConcurrent(t *testing.T) {
+ s, path := tmpStore(t)
+ var wg sync.WaitGroup
+ for i := 0; i < 20; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ s.AddHistory("http://c"+string(rune('a'+i%10))+".com", "")
+ }(i)
+ }
+ wg.Wait()
+
+ // 重新加载,验证落盘的 JSON 能正确解析且无损坏
+ s2 := newBrowserStore(path)
+ if len(s2.ListHistory()) == 0 {
+ t.Error("history lost after reload")
+ }
+ // 文件不是 .tmp 残留
+ if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
+ t.Error(".tmp leftover detected")
+ }
+}
diff --git a/pkg/console/dist/assets/index-BPLAoxgS.js b/pkg/console/dist/assets/index-BPLAoxgS.js
deleted file mode 100644
index ad41441..0000000
--- a/pkg/console/dist/assets/index-BPLAoxgS.js
+++ /dev/null
@@ -1,173 +0,0 @@
-var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l