fix(browser): 不可关闭、最小化可拖拽与重启
移除关闭入口;最小化为可拖动浮动条并持久化 minimized_rect;重启保留浏览历史。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`
|
||||
- 仅剩一个标签时不可关闭该标签
|
||||
|
||||
## 说明
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user