feat: add multi-tab support to remote browser

This commit is contained in:
liumangmang
2026-05-30 09:51:51 +08:00
parent 5c20ddc8e6
commit 3ab3a5e26f
7 changed files with 440 additions and 16 deletions
+34
View File
@@ -34,11 +34,21 @@ class BrowserSessionCreate(BaseModel):
height: int = Field(default=720) height: int = Field(default=720)
class BrowserTabResponse(BaseModel):
id: str
title: str
url: str
created_at: float
class BrowserSessionResponse(BaseModel): class BrowserSessionResponse(BaseModel):
id: str id: str
custom_page_id: int custom_page_id: int
url: str url: str
title: str title: str
active_tab_id: Optional[str] = None
tabs: Optional[list[BrowserTabResponse]] = None
tab_revision: Optional[int] = 0
class BrowserSelectionResponse(BaseModel): class BrowserSelectionResponse(BaseModel):
@@ -123,6 +133,22 @@ async def send_event(session_id: str, body: BrowserEvent, _=Depends(get_current_
raise _error_from_browser(exc) raise _error_from_browser(exc)
@router.post("/{session_id}/tabs/{tab_id}/activate", response_model=BrowserSessionResponse)
async def activate_tab(session_id: str, tab_id: str, _=Depends(get_current_user)):
try:
return await browser_sessions.activate_tab(session_id, tab_id)
except Exception as exc:
raise _error_from_browser(exc)
@router.delete("/{session_id}/tabs/{tab_id}", response_model=BrowserSessionResponse)
async def close_tab(session_id: str, tab_id: str, _=Depends(get_current_user)):
try:
return await browser_sessions.close_tab(session_id, tab_id)
except Exception as exc:
raise _error_from_browser(exc)
@router.get("/{session_id}/selection", response_model=BrowserSelectionResponse) @router.get("/{session_id}/selection", response_model=BrowserSelectionResponse)
async def get_selection(session_id: str, _=Depends(get_current_user)): async def get_selection(session_id: str, _=Depends(get_current_user)):
try: try:
@@ -314,6 +340,7 @@ async def session_ws(
# Task: push screenshots # Task: push screenshots
async def push_loop(): async def push_loop():
nonlocal last_frame_hash, unchanged_count nonlocal last_frame_hash, unchanged_count
last_tab_revision = -1
try: try:
while True: while True:
now = asyncio.get_event_loop().time() now = asyncio.get_event_loop().time()
@@ -327,6 +354,13 @@ async def session_ws(
interval = _WS_IDLE_INTERVAL interval = _WS_IDLE_INTERVAL
try: try:
# Check for tab state changes
session_obj = browser_sessions.get_session(session_id)
if session_obj.tab_revision != last_tab_revision:
last_tab_revision = session_obj.tab_revision
state = await browser_sessions.state(session_id)
await websocket.send_json({"type": "state", "session": state})
frame = await browser_sessions.screenshot(session_id) frame = await browser_sessions.screenshot(session_id)
except KeyError: except KeyError:
await websocket.send_json({"error": "session_not_found"}) await websocket.send_json({"error": "session_not_found"})
+139 -6
View File
@@ -24,18 +24,35 @@ class BrowserSessionError(RuntimeError):
"""Raised when an existing browser session can no longer be used.""" """Raised when an existing browser session can no longer be used."""
@dataclass
class BrowserTab:
id: str
page: Any
created_at: float
@dataclass @dataclass
class BrowserSession: class BrowserSession:
id: str id: str
custom_page_id: int custom_page_id: int
profile_key: str profile_key: str
context: Any context: Any
page: Any tabs: dict[str, BrowserTab]
active_tab_id: str
lock: asyncio.Lock lock: asyncio.Lock
tab_revision: int = 0
cdp_session: Any = None cdp_session: Any = None
captured_headers: list[dict] = None # auth headers from CDP captured_headers: list[dict] = None # auth headers from CDP
last_saved_state_at: float = 0.0 last_saved_state_at: float = 0.0
@property
def active_tab(self) -> BrowserTab:
return self.tabs[self.active_tab_id]
@property
def page(self) -> Any:
return self.active_tab.page
class BrowserSessionService: class BrowserSessionService:
# Idle TTL: close sessions that haven't had activity for this long # Idle TTL: close sessions that haven't had activity for this long
@@ -110,17 +127,22 @@ class BrowserSessionService:
except Exception: except Exception:
logger.debug("clipboard permission grant failed (non-fatal)") logger.debug("clipboard permission grant failed (non-fatal)")
page = context.pages[0] if context.pages else await context.new_page() page = context.pages[0] if context.pages else await context.new_page()
tab_id = uuid4().hex
tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time())
session = BrowserSession( session = BrowserSession(
id=uuid4().hex, id=uuid4().hex,
custom_page_id=custom_page_id, custom_page_id=custom_page_id,
profile_key=profile_key, profile_key=profile_key,
context=context, context=context,
page=page, tabs={tab_id: tab},
active_tab_id=tab_id,
lock=asyncio.Lock(), lock=asyncio.Lock(),
) )
self._sessions[session.id] = session self._sessions[session.id] = session
self._profiles[profile_key] = session.id self._profiles[profile_key] = session.id
self._touch(session.id) self._touch(session.id)
# Register page capture for multi-tab support
context.on("page", lambda p: self._handle_new_page(session, p))
# Evict again after adding the new session so cap is enforced immediately # Evict again after adding the new session so cap is enforced immediately
await self._evict_idle_sessions() await self._evict_idle_sessions()
try: try:
@@ -136,6 +158,33 @@ class BrowserSessionService:
"""Mark a session as recently active (reset idle timer).""" """Mark a session as recently active (reset idle timer)."""
self._last_event_at[session_id] = asyncio.get_event_loop().time() self._last_event_at[session_id] = asyncio.get_event_loop().time()
def _handle_new_page(self, session: BrowserSession, page: Any) -> None:
"""Capture a new page opened by the remote browser (e.g. target="_blank")."""
tab_id = uuid4().hex
tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time())
session.tabs[tab_id] = tab
session.active_tab_id = tab_id
session.tab_revision += 1
logger.info("session %s: captured new tab %s (total: %d)", session.id[:12], tab_id[:8], len(session.tabs))
# Best-effort: bring to front and reset zoom
asyncio.create_task(self._init_new_tab(session, tab))
async def _init_new_tab(self, session: BrowserSession, tab: BrowserTab) -> None:
try:
await tab.page.bring_to_front()
await self._reset_page_zoom(session)
# Grant clipboard permission for the new page's origin if possible
try:
url = tab.page.url
if url.startswith("http"):
parsed = urlparse(url)
origin = f"{parsed.scheme}://{parsed.netloc}"
await session.context.grant_permissions(["clipboard-read", "clipboard-write"], origin=origin)
except Exception:
pass
except Exception as exc:
logger.debug("new tab %s init failed: %s", tab.id[:8], exc)
async def screenshot(self, session_id: str) -> bytes: async def screenshot(self, session_id: str) -> bytes:
session = self._get(session_id) session = self._get(session_id)
self._touch(session_id) self._touch(session_id)
@@ -309,12 +358,82 @@ class BrowserSessionService:
self._ensure_open(session) self._ensure_open(session)
return await self._session_state(session) return await self._session_state(session)
async def activate_tab(self, session_id: str, tab_id: str) -> dict[str, Any]:
session = self._get(session_id)
self._touch(session_id)
async with session.lock:
self._ensure_open(session)
if tab_id not in session.tabs:
raise KeyError("tab not found")
session.active_tab_id = tab_id
session.tab_revision += 1
await session.page.bring_to_front()
return await self._session_state(session)
async def close_tab(self, session_id: str, tab_id: str) -> dict[str, Any]:
session = self._get(session_id)
self._touch(session_id)
async with session.lock:
self._ensure_open(session)
if tab_id not in session.tabs:
raise KeyError("tab not found")
if len(session.tabs) <= 1:
raise ValueError("cannot close the last tab")
tab = session.tabs.pop(tab_id)
try:
await tab.page.close()
except Exception:
pass
if session.active_tab_id == tab_id:
# Pick the latest remaining tab
latest = max(session.tabs.values(), key=lambda t: t.created_at)
session.active_tab_id = latest.id
await session.page.bring_to_front()
session.tab_revision += 1
return await self._session_state(session)
async def _session_state(self, session: BrowserSession) -> dict[str, Any]: async def _session_state(self, session: BrowserSession) -> dict[str, Any]:
tabs = []
# We might need to prune closed pages during state generation too
closed_ids = []
for tid, tab in session.tabs.items():
if tab.page.is_closed():
closed_ids.append(tid)
continue
try:
title = await tab.page.title()
url = tab.page.url
except Exception:
title, url = "Loading...", "about:blank"
tabs.append({
"id": tid,
"title": title,
"url": url,
"created_at": tab.created_at,
})
if closed_ids:
for cid in closed_ids:
session.tabs.pop(cid, None)
if not session.tabs:
raise BrowserSessionError("all browser pages are closed")
if session.active_tab_id in closed_ids:
latest = max(session.tabs.values(), key=lambda t: t.created_at)
session.active_tab_id = latest.id
session.tab_revision += 1
tabs.sort(key=lambda x: x["created_at"])
return { return {
"id": session.id, "id": session.id,
"custom_page_id": session.custom_page_id, "custom_page_id": session.custom_page_id,
"url": session.page.url, "url": session.page.url,
"title": await session.page.title(), "title": await session.page.title(),
"active_tab_id": session.active_tab_id,
"tabs": tabs,
"tab_revision": session.tab_revision,
} }
async def _ensure_playwright(self) -> None: async def _ensure_playwright(self) -> None:
@@ -470,9 +589,18 @@ class BrowserSessionService:
_get = get_session # alias for internal use _get = get_session # alias for internal use
def _ensure_open(self, session: BrowserSession) -> None: def _ensure_open(self, session: BrowserSession) -> None:
if session.page.is_closed(): if session.active_tab.page.is_closed():
self._discard_session(session.id) # Current tab closed? Try to cleanup and find another one
raise BrowserSessionError("browser page is closed") session.tabs.pop(session.active_tab_id, None)
if session.tabs:
# Pick the latest created tab
latest = max(session.tabs.values(), key=lambda t: t.created_at)
session.active_tab_id = latest.id
session.tab_revision += 1
logger.info("active tab closed, switched to %s", latest.id[:8])
else:
self._discard_session(session.id)
raise BrowserSessionError("all browser pages are closed")
def _discard_session(self, session_id: str) -> BrowserSession | None: def _discard_session(self, session_id: str) -> BrowserSession | None:
session = self._sessions.pop(session_id, None) session = self._sessions.pop(session_id, None)
@@ -592,17 +720,22 @@ class BrowserSessionService:
except Exception: except Exception:
logger.debug("clipboard permission grant failed (non-fatal)") logger.debug("clipboard permission grant failed (non-fatal)")
page = context.pages[0] if context.pages else await context.new_page() page = context.pages[0] if context.pages else await context.new_page()
tab_id = uuid4().hex
tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time())
session = BrowserSession( session = BrowserSession(
id=session_id, id=session_id,
custom_page_id=0, custom_page_id=0,
profile_key=profile_key, profile_key=profile_key,
context=context, context=context,
page=page, tabs={tab_id: tab},
active_tab_id=tab_id,
lock=asyncio.Lock(), lock=asyncio.Lock(),
captured_headers=[], captured_headers=[],
) )
self._sessions[session.id] = session self._sessions[session.id] = session
self._touch(session.id) self._touch(session.id)
# Register page capture
context.on("page", lambda p: self._handle_new_page(session, p))
# Start CDP network capture BEFORE the initial page load, # Start CDP network capture BEFORE the initial page load,
# so we capture login redirects and auth headers from the start. # so we capture login redirects and auth headers from the start.
await self._start_cdp_capture(session) await self._start_cdp_capture(session)
+13 -8
View File
@@ -159,7 +159,7 @@ def test_screenshot_throttled_save():
try: try:
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from app.services.browser_session_service import BrowserSession from app.services.browser_session_service import BrowserSession, BrowserTab
# Mock Context & Page # Mock Context & Page
fake_context = AsyncMock() fake_context = AsyncMock()
@@ -172,7 +172,8 @@ def test_screenshot_throttled_save():
custom_page_id=1, custom_page_id=1,
profile_key="page-1-test", profile_key="page-1-test",
context=fake_context, context=fake_context,
page=fake_page, tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
active_tab_id="main",
lock=asyncio.Lock(), lock=asyncio.Lock(),
last_saved_state_at=0.0 last_saved_state_at=0.0
) )
@@ -204,7 +205,8 @@ def test_screenshot_throttled_save():
custom_page_id=0, custom_page_id=0,
profile_key="auth-capture-xyz", profile_key="auth-capture-xyz",
context=fake_context, context=fake_context,
page=fake_page, tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
active_tab_id="main",
lock=asyncio.Lock(), lock=asyncio.Lock(),
last_saved_state_at=0.0 last_saved_state_at=0.0
) )
@@ -228,7 +230,7 @@ def test_close_saves_state_and_cleans_up():
try: try:
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from app.services.browser_session_service import BrowserSession from app.services.browser_session_service import BrowserSession, BrowserTab
fake_context = AsyncMock() fake_context = AsyncMock()
fake_page = MagicMock() fake_page = MagicMock()
@@ -240,7 +242,8 @@ def test_close_saves_state_and_cleans_up():
custom_page_id=2, custom_page_id=2,
profile_key="page-2-test", profile_key="page-2-test",
context=fake_context, context=fake_context,
page=fake_page, tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
active_tab_id="main",
lock=asyncio.Lock(), lock=asyncio.Lock(),
last_saved_state_at=time.monotonic() # 此时在限流内 last_saved_state_at=time.monotonic() # 此时在限流内
) )
@@ -260,7 +263,8 @@ def test_close_saves_state_and_cleans_up():
custom_page_id=0, custom_page_id=0,
profile_key="auth-capture-abc", profile_key="auth-capture-abc",
context=eph_context, context=eph_context,
page=eph_page, tabs={"main": BrowserTab(id="main", page=eph_page, created_at=0.0)},
active_tab_id="main",
lock=asyncio.Lock(), lock=asyncio.Lock(),
last_saved_state_at=0.0 last_saved_state_at=0.0
) )
@@ -382,7 +386,7 @@ def test_websocket_event_saves_state():
try: try:
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from app.services.browser_session_service import BrowserSession from app.services.browser_session_service import BrowserSession, BrowserTab
fake_context = AsyncMock() fake_context = AsyncMock()
fake_page = MagicMock() fake_page = MagicMock()
@@ -395,7 +399,8 @@ def test_websocket_event_saves_state():
custom_page_id=3, custom_page_id=3,
profile_key="page-3-ws-test", profile_key="page-3-ws-test",
context=fake_context, context=fake_context,
page=fake_page, tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
active_tab_id="main",
lock=asyncio.Lock(), lock=asyncio.Lock(),
last_saved_state_at=0.0 last_saved_state_at=0.0
) )
+141
View File
@@ -0,0 +1,141 @@
import asyncio
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from app.services.browser_session_service import BrowserSessionService, BrowserSession, BrowserTab, BrowserSessionError
@pytest.fixture
def service():
return BrowserSessionService()
@pytest.fixture
def session(service):
fake_context = AsyncMock()
fake_page = AsyncMock()
fake_page.is_closed = MagicMock(return_value=False)
fake_page.url = "https://initial.test"
fake_page.title = AsyncMock(return_value="Initial Tab")
tab_id = "tab1"
tab = BrowserTab(id=tab_id, page=fake_page, created_at=100.0)
sess = BrowserSession(
id="sess123",
custom_page_id=1,
profile_key="profile1",
context=fake_context,
tabs={tab_id: tab},
active_tab_id=tab_id,
lock=asyncio.Lock(),
)
service._sessions[sess.id] = sess
return sess
@pytest.mark.asyncio
async def test_tab_capture(service, session):
new_page = AsyncMock()
new_page.is_closed = MagicMock(return_value=False)
new_page.url = "https://new.test"
new_page.title = AsyncMock(return_value="New Tab")
new_page.bring_to_front = AsyncMock()
# Simulate page capture
service._handle_new_page(session, new_page)
assert len(session.tabs) == 2
assert session.active_tab_id != "tab1"
new_tab_id = session.active_tab_id
assert session.tabs[new_tab_id].page == new_page
assert session.tab_revision == 1
# Wait a bit for the background task _init_new_tab to finish if possible,
# though it's mocked anyway.
await asyncio.sleep(0.1)
@pytest.mark.asyncio
async def test_activate_tab(service, session):
new_page = AsyncMock()
new_page.is_closed = MagicMock(return_value=False)
new_page.bring_to_front = AsyncMock()
tab2_id = "tab2"
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=new_page, created_at=200.0)
await service.activate_tab(session.id, tab2_id)
assert session.active_tab_id == tab2_id
assert new_page.bring_to_front.call_count == 1
assert session.tab_revision == 1
@pytest.mark.asyncio
async def test_close_tab_safety(service, session):
# Cannot close last tab
with pytest.raises(ValueError, match="cannot close the last tab"):
await service.close_tab(session.id, "tab1")
@pytest.mark.asyncio
async def test_close_active_tab_fallback(service, session):
# Setup tab2
page2 = AsyncMock()
page2.is_closed = MagicMock(return_value=False)
page2.bring_to_front = AsyncMock()
tab2_id = "tab2"
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=page2, created_at=200.0)
# Active is tab2
session.active_tab_id = tab2_id
# Close active tab2
await service.close_tab(session.id, tab2_id)
assert len(session.tabs) == 1
assert session.active_tab_id == "tab1"
assert tab2_id not in session.tabs
assert session.tabs["tab1"].page.bring_to_front.call_count == 1
@pytest.mark.asyncio
async def test_session_state_includes_tabs(service, session):
# Setup tab2
page2 = AsyncMock()
page2.is_closed = MagicMock(return_value=False)
page2.url = "https://tab2.test"
page2.title = AsyncMock(return_value="Tab 2")
tab2_id = "tab2"
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=page2, created_at=200.0)
state = await service.state(session.id)
assert state["id"] == session.id
assert state["active_tab_id"] == "tab1"
assert len(state["tabs"]) == 2
# Tabs should be sorted by created_at
assert state["tabs"][0]["id"] == "tab1"
assert state["tabs"][1]["id"] == "tab2"
assert state["tabs"][0]["title"] == "Initial Tab"
assert state["tabs"][1]["url"] == "https://tab2.test"
@pytest.mark.asyncio
async def test_ensure_open_prunes_closed_tab(service, session):
# Setup tab2 and make it active
page2 = AsyncMock()
page2.is_closed = MagicMock(return_value=True) # Page 2 is closed
tab2_id = "tab2"
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=page2, created_at=200.0)
session.active_tab_id = tab2_id
# Calling any interaction method should trigger _ensure_open
# and fallback to tab1
await service.screenshot(session.id)
assert session.active_tab_id == "tab1"
assert tab2_id not in session.tabs
assert session.tab_revision == 1
@pytest.mark.asyncio
async def test_ensure_open_discards_session_if_all_tabs_closed(service, session):
session.tabs["tab1"].page.is_closed = MagicMock(return_value=True)
with pytest.raises(BrowserSessionError, match="all browser pages are closed"):
await service.screenshot(session.id)
assert session.id not in service._sessions
+14
View File
@@ -397,11 +397,21 @@ export const customPagesApi = {
} }
// ——— Remote browser sessions ——— // ——— Remote browser sessions ———
export interface BrowserTabData {
id: string
title: string
url: string
created_at: number
}
export interface BrowserSessionData { export interface BrowserSessionData {
id: string id: string
custom_page_id: number custom_page_id: number
url: string url: string
title: string title: string
active_tab_id?: string
tabs?: BrowserTabData[]
tab_revision?: number
} }
export type BrowserEventPayload = export type BrowserEventPayload =
@@ -418,6 +428,10 @@ export const browserSessionsApi = {
get: (id: string) => api.get<BrowserSessionData>(`/api/browser-sessions/${id}`), get: (id: string) => api.get<BrowserSessionData>(`/api/browser-sessions/${id}`),
event: (id: string, data: BrowserEventPayload) => event: (id: string, data: BrowserEventPayload) =>
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data), api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
activateTab: (id: string, tabId: string) =>
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/tabs/${tabId}/activate`),
closeTab: (id: string, tabId: string) =>
api.delete<BrowserSessionData>(`/api/browser-sessions/${id}/tabs/${tabId}`),
selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`), selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`),
clipboard: (id: string) => api.get<{ text?: string; error?: string }>(`/api/browser-sessions/${id}/clipboard`), clipboard: (id: string) => api.get<{ text?: string; error?: string }>(`/api/browser-sessions/${id}/clipboard`),
close: (id: string) => api.delete(`/api/browser-sessions/${id}`), close: (id: string) => api.delete(`/api/browser-sessions/${id}`),
+98 -2
View File
@@ -64,6 +64,26 @@
<div class="viewer-body"> <div class="viewer-body">
<div v-if="isRemoteBrowser" class="viewer-content viewer-content-remote"> <div v-if="isRemoteBrowser" class="viewer-content viewer-content-remote">
<div class="viewer-stage" :class="{ 'viewer-stage-error': showRemoteError }"> <div class="viewer-stage" :class="{ 'viewer-stage-error': showRemoteError }">
<!-- Multi-tab bar -->
<div v-if="!showRemoteError && remoteSession?.tabs?.length" class="remote-tabs">
<div
v-for="tab in remoteSession.tabs"
:key="tab.id"
class="remote-tab"
:class="{ active: tab.id === remoteSession.active_tab_id }"
@click="switchRemoteTab(tab.id)"
>
<span class="tab-title" :title="tab.url">{{ tab.title || 'Loading...' }}</span>
<el-icon
v-if="remoteSession.tabs.length > 1"
class="tab-close"
@click.stop="closeRemoteTab(tab.id)"
>
<Close />
</el-icon>
</div>
</div>
<div <div
v-if="!showRemoteError" v-if="!showRemoteError"
ref="remoteFrameRef" ref="remoteFrameRef"
@@ -153,7 +173,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { import {
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy, Delete, EditPen, ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy, Delete, EditPen,
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine, Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House, Grid, Connection, Ticket, Wallet, Key, Tools, Star, House, Close,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { browserSessionsApi, customPagesApi, type BrowserEventPayload, type BrowserSessionData, type CustomPageData } from '@/api' import { browserSessionsApi, customPagesApi, type BrowserEventPayload, type BrowserSessionData, type CustomPageData } from '@/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -396,7 +416,7 @@ function connectRemoteWs() {
// Text frame = JSON control message // Text frame = JSON control message
try { try {
const msg = JSON.parse(evt.data as string) const msg = JSON.parse(evt.data as string)
if (msg.type === 'init' && msg.session) { if ((msg.type === 'init' || msg.type === 'state') && msg.session) {
remoteSession.value = msg.session as BrowserSessionData remoteSession.value = msg.session as BrowserSessionData
return return
} }
@@ -477,6 +497,26 @@ function sendRemoteCommand(type: 'reload' | 'back' | 'forward') {
sendRemoteEvent({ type }) sendRemoteEvent({ type })
} }
async function switchRemoteTab(tabId: string) {
if (!remoteSession.value || remoteSession.value.active_tab_id === tabId) return
try {
const res = await browserSessionsApi.activateTab(remoteSession.value.id, tabId)
remoteSession.value = res.data
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '切换标签页失败')
}
}
async function closeRemoteTab(tabId: string) {
if (!remoteSession.value) return
try {
const res = await browserSessionsApi.closeTab(remoteSession.value.id, tabId)
remoteSession.value = res.data
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '关闭标签页失败')
}
}
async function copyRemoteSelection() { async function copyRemoteSelection() {
if (!remoteSession.value) return if (!remoteSession.value) return
try { try {
@@ -1138,6 +1178,62 @@ onBeforeUnmount(() => {
var(--bg-surface); var(--bg-surface);
} }
.remote-tabs {
display: flex;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(140, 119, 98, 0.15);
padding: 4px 6px 0;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.remote-tabs::-webkit-scrollbar { display: none; }
.remote-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px 8px 0 0;
color: var(--text-soft);
font-size: 11px;
cursor: pointer;
white-space: nowrap;
max-width: 180px;
transition: all 0.2s;
border: 1px solid transparent;
border-bottom: none;
}
.remote-tab:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.remote-tab.active {
background: var(--bg-surface);
color: var(--text-primary);
font-weight: 600;
border-color: rgba(140, 119, 98, 0.15);
}
.tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-close {
font-size: 12px;
padding: 2px;
border-radius: 4px;
}
.tab-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #f56c6c;
}
.page-iframe { .page-iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
+1
View File
@@ -15,6 +15,7 @@
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"ignoreDeprecations": "5.0",
"baseUrl": ".", "baseUrl": ".",
"paths": { "@/*": ["src/*"] } "paths": { "@/*": ["src/*"] }
}, },