fix(browser): 不可关闭、最小化可拖拽与重启

移除关闭入口;最小化为可拖动浮动条并持久化 minimized_rect;重启保留浏览历史。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-11 20:15:40 +08:00
parent a94d340ec0
commit 2e8daad802
6 changed files with 197 additions and 87 deletions
@@ -41,4 +41,4 @@
## 验证
浮动打开 → 最小化右下角 → 左下角展开 → 切换菜单 iframe 仍在。
浮动打开 → 最小化可拖动浮动条 → 左下角展开 → 不可关闭仅可重启 → 切换菜单 iframe 仍在。
@@ -34,7 +34,13 @@ pytest tests/test_browser_ui_state.py -q
cd frontend && npm run type-check
```
左下角地球按钮打开;最小化后右下角;历史按钮可见记录;换账号后记录隔离。
左下角地球按钮打开;最小化后为可拖动浮动条(默认右下角;历史按钮可见记录;换账号后记录隔离。
## 2026-06-11 更新 — 不可关闭、最小化可拖拽
- 移除「关闭浏览器」按钮;仅 **最小化****重启**(重启保留浏览历史)
- 最小化后为全局 **可拖动浮动条**(双击展开),位置持久化 `minimized_rect`
- 仅剩一个标签时不可关闭该标签
## 说明
+121 -47
View File
@@ -27,6 +27,7 @@
<v-tab v-for="tab in tabs" :key="tab.id" :value="tab.id" class="text-none px-2">
<span class="text-truncate" style="max-width: 120px">{{ tabLabel(tab) }}</span>
<v-btn
v-if="tabs.length > 1"
icon="mdi-close"
size="x-small"
variant="text"
@@ -59,6 +60,16 @@
@click="showHistory = !showHistory"
/>
<v-btn
size="small"
variant="text"
icon="mdi-restart"
title="重启浏览器(保留历史记录)"
density="compact"
@mousedown.stop
@click="onRestart"
/>
<v-btn
size="small"
variant="text"
@@ -71,20 +82,11 @@
size="small"
variant="text"
icon="mdi-window-minimize"
title="最小化到右下角"
density="compact"
@mousedown.stop
@click="minimizePanel"
/>
<v-btn
size="small"
variant="text"
icon="mdi-close"
title="关闭浏览器"
title="最小化(可拖动)"
density="compact"
class="mr-1"
@mousedown.stop
@click="closePanel"
@click="minimizePanel"
/>
</div>
@@ -147,7 +149,7 @@
<v-icon icon="mdi-web" size="48" class="mb-3 opacity-50" />
<p class="text-body-2 text-center">输入地址后按回车或点打开</p>
<p class="text-caption mt-2 text-center">
页面在您当前的 Chrome / Firefox 等浏览器引擎中渲染切换侧栏菜单时浏览器保持活动
浏览器不可关闭仅可最小化或重启页面在您当前的 Chrome / Firefox 引擎中渲染
</p>
</div>
<template v-else>
@@ -175,21 +177,43 @@
</v-card>
</div>
<v-card
<!-- Minimized draggable floating bar (any corner) -->
<div
v-if="minimized && tabs.length"
border
rounded="lg"
class="global-browser-dock-br"
class="global-browser-mini-float"
:style="miniPanelStyle"
>
<v-icon icon="mdi-web" size="18" class="mr-2" />
<span class="text-caption text-truncate global-browser-dock-label" @click="openPanel">
<v-card border rounded="lg" class="global-browser-mini-panel h-100 d-flex flex-column">
<div
class="global-browser-mini-toolbar d-flex align-center px-2 flex-grow-1 global-browser-toolbar--draggable"
@mousedown="onMiniToolbarMouseDown"
@dblclick="openPanel"
>
<v-icon size="16" class="mr-1 text-disabled flex-shrink-0" @mousedown.stop>mdi-drag</v-icon>
<v-icon icon="mdi-web" size="18" class="mr-2 flex-shrink-0" />
<span class="text-caption text-truncate flex-grow-1 min-w-0" @click="openPanel">
{{ activeTab ? tabLabel(activeTab) : '浏览器' }}
</span>
<v-chip v-if="tabs.length > 1" size="x-small" class="ml-2">{{ tabs.length }}</v-chip>
<v-spacer />
<v-btn size="x-small" variant="text" icon="mdi-dock-window" title="展开" @click="openPanel" />
<v-btn size="x-small" variant="text" icon="mdi-close" title="关闭" @click="closePanel" />
<v-chip v-if="tabs.length > 1" size="x-small" class="mx-1 flex-shrink-0">{{ tabs.length }}</v-chip>
<v-btn
size="x-small"
variant="text"
icon="mdi-restart"
title="重启浏览器"
@mousedown.stop
@click.stop="onRestart"
/>
<v-btn
size="x-small"
variant="text"
icon="mdi-dock-window"
title="展开"
@mousedown.stop
@click.stop="openPanel"
/>
</div>
</v-card>
</div>
<v-btn
v-if="showLauncher"
@@ -198,10 +222,22 @@
icon="mdi-web"
size="large"
elevation="4"
title="打开 / 展开浏览器"
title="展开浏览器"
@click="onLauncherClick"
/>
</Teleport>
<v-dialog v-model="showRestartConfirm" max-width="400">
<v-card border>
<v-card-title>重启浏览器</v-card-title>
<v-card-text>将关闭所有标签并打开空白页浏览历史记录会保留</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRestartConfirm = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="confirmRestart">重启</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
@@ -212,6 +248,7 @@ import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
defineProps<{ showLauncher: boolean }>()
const showHistory = ref(false)
const showRestartConfirm = ref(false)
const maximized = ref(false)
const {
@@ -224,12 +261,13 @@ const {
activeTabId,
activeTab,
panelRect,
minimizedRect,
canGoBack,
canGoForward,
tabLabel,
openPanel,
minimizePanel,
closePanel,
restartBrowser,
navigate,
refreshActive,
goBack,
@@ -240,21 +278,45 @@ const {
newEmptyTab,
openFromHistory,
savePanelRect,
saveMinimizedRect,
bootstrap,
} = useGlobalBrowser()
const fallback: FloatRect = { x: 80, y: 72, w: 980, h: 640 }
function defaultMiniRect(): FloatRect {
const w = 320
const h = 52
return {
x: Math.max(16, window.innerWidth - w - 16),
y: Math.max(16, window.innerHeight - h - 16),
w,
h,
}
}
const panelFallback: FloatRect = { x: 80, y: 72, w: 980, h: 640 }
const { rect, panelStyle, startDrag, startResize, centerOnScreen } = useFloatingPanel(
'nexus_global_browser_rect_v1',
fallback,
panelFallback,
)
const {
rect: miniRect,
panelStyle: miniPanelStyle,
startDrag: startMiniDrag,
clampToViewport: clampMiniViewport,
} = useFloatingPanel('nexus_global_browser_mini_rect_v1', defaultMiniRect())
function seedRectFromServer() {
if (panelRect.value) {
rect.value = { ...panelRect.value }
return
}
} else {
centerOnScreen()
}
if (minimizedRect.value) {
miniRect.value = { ...minimizedRect.value }
} else {
miniRect.value = defaultMiniRect()
}
}
onMounted(async () => {
@@ -266,11 +328,21 @@ watch(rect, (value) => {
savePanelRect({ ...value })
}, { deep: true })
watch(miniRect, (value) => {
saveMinimizedRect({ ...value })
}, { deep: true })
function onToolbarMouseDown(e: MouseEvent) {
if (maximized.value) return
startDrag(e)
}
function onMiniToolbarMouseDown(e: MouseEvent) {
if (e.button !== 0) return
startMiniDrag(e)
clampMiniViewport()
}
function onResizeStart(e: MouseEvent) {
startResize(e)
}
@@ -290,10 +362,20 @@ function onLauncherClick() {
}
newEmptyTab()
}
function onRestart() {
showRestartConfirm.value = true
}
function confirmRestart() {
showRestartConfirm.value = false
restartBrowser()
}
</script>
<style scoped>
.global-browser-float {
.global-browser-float,
.global-browser-mini-float {
position: fixed;
z-index: 2350;
display: flex;
@@ -307,19 +389,27 @@ function onLauncherClick() {
left: 0 !important;
top: 0 !important;
}
.global-browser-panel {
.global-browser-panel,
.global-browser-mini-panel {
overflow: hidden;
position: relative;
height: 100%;
background: rgb(var(--v-theme-surface));
}
.global-browser-toolbar {
.global-browser-toolbar,
.global-browser-mini-toolbar {
height: 40px;
min-height: 40px;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.global-browser-mini-toolbar {
height: 100%;
min-height: 0;
border-bottom: none;
}
.global-browser-toolbar--draggable {
cursor: move;
user-select: none;
}
.global-browser-nav {
background: rgb(var(--v-theme-surface));
@@ -363,22 +453,6 @@ function onLauncherClick() {
rgba(var(--v-theme-on-surface), 0.25) 50%
);
}
.global-browser-dock-br {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 2340;
max-width: min(420px, calc(100vw - 120px));
padding: 8px 12px;
display: flex;
align-items: center;
gap: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
}
.global-browser-dock-label {
max-width: 200px;
cursor: pointer;
}
.global-browser-launcher {
position: fixed;
left: 16px;
+27 -11
View File
@@ -38,6 +38,7 @@ export interface BrowserStateResponse {
active_tab_id: string | null
minimized: boolean
rect: FloatRect | null
minimized_rect: FloatRect | null
}
const bootstrapped = ref(false)
@@ -52,6 +53,7 @@ const visits = ref<BrowserVisit[]>([])
const tabs = ref<BrowserTab[]>([])
const activeTabId = ref<string | null>(null)
const panelRect = ref<FloatRect | null>(null)
const minimizedRect = ref<FloatRect | null>(null)
let saveTimer: ReturnType<typeof setTimeout> | null = null
@@ -98,6 +100,7 @@ function applyServerState(data: BrowserStateResponse) {
}
minimized.value = Boolean(data.minimized)
panelRect.value = data.rect
minimizedRect.value = data.minimized_rect
panelOpen.value = tabs.value.length > 0 && !minimized.value
syncAddressFromActive()
}
@@ -136,6 +139,7 @@ async function persistState(extra?: { recordUrl?: string; recordTitle?: string }
active_tab_id: activeTabId.value,
minimized: minimized.value,
rect: panelRect.value,
minimized_rect: minimizedRect.value,
}
if (extra?.recordUrl) {
body.record_url = extra.recordUrl
@@ -200,13 +204,21 @@ function minimizePanel() {
scheduleSave()
}
function closePanel() {
tabs.value = []
activeTabId.value = null
panelOpen.value = false
minimized.value = false
maximized.value = false
/** Reset to a single blank tab; browsing history is kept. */
function restartBrowser() {
const tab: BrowserTab = {
id: newTabId(),
url: '',
title: '新标签',
stack: [],
stackIndex: -1,
frameKey: 0,
}
tabs.value = [tab]
activeTabId.value = tab.id
addressDraft.value = ''
lastError.value = null
openPanel()
scheduleSave()
}
@@ -289,6 +301,7 @@ function switchTab(id: string) {
}
function closeTab(id: string) {
if (tabs.value.length <= 1) return
const idx = tabs.value.findIndex((t) => t.id === id)
if (idx < 0) return
const next = tabs.value.filter((t) => t.id !== id)
@@ -296,10 +309,6 @@ function closeTab(id: string) {
if (activeTabId.value === id) {
activeTabId.value = next[idx]?.id ?? next[idx - 1]?.id ?? null
}
if (!next.length) {
closePanel()
return
}
syncAddressFromActive()
scheduleSave()
}
@@ -329,6 +338,11 @@ function savePanelRect(rect: FloatRect) {
scheduleSave()
}
function saveMinimizedRect(rect: FloatRect) {
minimizedRect.value = rect
scheduleSave()
}
export function useGlobalBrowser() {
return {
bootstrapped,
@@ -344,6 +358,7 @@ export function useGlobalBrowser() {
activeTabId,
activeTab,
panelRect,
minimizedRect,
isActive,
canGoBack,
canGoForward,
@@ -351,7 +366,7 @@ export function useGlobalBrowser() {
bootstrap,
openPanel,
minimizePanel,
closePanel,
restartBrowser,
openUrl,
navigate,
refreshActive,
@@ -363,6 +378,7 @@ export function useGlobalBrowser() {
newEmptyTab,
openFromHistory,
savePanelRect,
saveMinimizedRect,
scheduleSave,
}
}
+3
View File
@@ -47,6 +47,7 @@ class BrowserStateUpdate(BaseModel):
active_tab_id: str | None = None
minimized: bool | None = None
rect: BrowserPanelRect | None = None
minimized_rect: BrowserPanelRect | None = None
record_url: str | None = Field(default=None, description="Append one visit + history entry")
record_title: str | None = None
@@ -75,6 +76,8 @@ async def put_browser_state(
incoming["tabs"] = [t.model_dump(exclude_none=True) for t in payload.tabs]
if payload.rect is not None:
incoming["rect"] = payload.rect.model_dump()
if payload.minimized_rect is not None:
incoming["minimized_rect"] = payload.minimized_rect.model_dump()
merged = merge_browser_state(current, incoming)
if payload.record_url:
+27 -16
View File
@@ -43,9 +43,24 @@ def empty_browser_state() -> dict[str, Any]:
"active_tab_id": None,
"minimized": False,
"rect": None,
"minimized_rect": None,
}
def _parse_rect(raw: dict | None, *, default_w: float, default_h: float) -> dict[str, float] | None:
if raw is None or not isinstance(raw, dict):
return None
try:
return {
"x": float(raw.get("x", 0)),
"y": float(raw.get("y", 0)),
"w": float(raw.get("w", default_w)),
"h": float(raw.get("h", default_h)),
}
except (TypeError, ValueError):
return None
def parse_browser_state(raw: dict | None) -> dict[str, Any]:
base = empty_browser_state()
if not raw or not isinstance(raw, dict):
@@ -78,8 +93,11 @@ def parse_browser_state(raw: dict | None) -> dict[str, Any]:
url = item.get("url")
if not isinstance(tab_id, str) or not tab_id.strip():
continue
if not isinstance(url, str) or not _is_safe_url(url):
if not isinstance(url, str):
continue
if url.strip() and not _is_safe_url(url):
continue
if url.strip():
stack = [
u for u in item.get("stack") or [url]
if isinstance(u, str) and _is_safe_url(u)
@@ -88,10 +106,13 @@ def parse_browser_state(raw: dict | None) -> dict[str, Any]:
if not isinstance(stack_index, int):
stack_index = len(stack) - 1
stack_index = max(0, min(stack_index, len(stack) - 1))
else:
stack = []
stack_index = -1
tabs.append({
"id": tab_id.strip()[:64],
"url": url,
"title": str(item.get("title") or _hostname(url))[:200],
"url": url.strip(),
"title": str(item.get("title") or (_hostname(url) if url.strip() else "新标签"))[:200],
"stack": stack[-64:],
"stack_index": stack_index,
})
@@ -101,19 +122,8 @@ def parse_browser_state(raw: dict | None) -> dict[str, Any]:
if not isinstance(active_tab_id, str) or not any(t["id"] == active_tab_id for t in tabs):
active_tab_id = tabs[0]["id"] if tabs else None
rect = raw.get("rect")
if rect is not None and not isinstance(rect, dict):
rect = None
if isinstance(rect, dict):
try:
rect = {
"x": float(rect.get("x", 0)),
"y": float(rect.get("y", 0)),
"w": float(rect.get("w", 960)),
"h": float(rect.get("h", 640)),
}
except (TypeError, ValueError):
rect = None
rect = _parse_rect(raw.get("rect"), default_w=960, default_h=640)
minimized_rect = _parse_rect(raw.get("minimized_rect"), default_w=320, default_h=52)
minimized = bool(raw.get("minimized"))
@@ -124,6 +134,7 @@ def parse_browser_state(raw: dict | None) -> dict[str, Any]:
"active_tab_id": active_tab_id,
"minimized": minimized,
"rect": rect,
"minimized_rect": minimized_rect,
}