fix: 移除 iframe 浏览器并优化推送页服务器展示

删除内置浏览器前后端;推送页支持卡片/列表切换、点击名称复制与长名称展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-12 01:43:51 +08:00
parent 17abd62261
commit 540712500f
19 changed files with 415 additions and 1233 deletions
@@ -0,0 +1,48 @@
# 审计 — 移除内置浏览器 + 推送页服务器 UI
**Changelog**: `docs/changelog/2026-06-11-remove-embedded-browser.md` · `docs/changelog/2026-06-11-push-server-name-display.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/App.vue` | 移除浏览器侧栏与 GlobalBrowserPanel |
| `frontend/src/router/index.ts` | 移除 `/browser` |
| `frontend/src/pages/ServersPage.vue` | 站点 → 新标签打开 |
| `frontend/src/components/GlobalBrowserPanel.vue` | 删除 |
| `frontend/src/composables/useGlobalBrowser.ts` | 删除 |
| `frontend/src/pages/BrowserPage.vue` | 删除 |
| `server/api/browser.py` | 删除 |
| `server/utils/browser_ui_state.py` | 删除 |
| `server/main.py` | 注销 browser 路由 |
| `frontend/src/components/push/PushServerGrid.vue` | 卡片/列表切换、名称复制 |
| `frontend/src/composables/push/usePushServerViewMode.ts` | 视图偏好 localStorage |
| `frontend/src/components/push/PushProgressList.vue` | 名称 truncate + title |
| `frontend/src/components/push/PushHistoryTable.vue` | 服务器列 slot |
| `frontend/src/components/push/PushPreviewDialog.vue` | 名称 truncate + title |
| `frontend/src/composables/push/usePushLogs.ts` | 列宽 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | SSRF / 安全 | PASS — 删除 iframe 浏览器 API;推送页无新后端 |
| H2 | 无静默吞错 | PASS |
| H3 | clipboard | PASS — copy 失败有 snackbar |
| H4 | CUD 审计 | N/A — 删除 browser state API |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 前端 UI + 删除 dead code |
## DoD
- [x] `npm run type-check`
- [x] `import server.main`
- [ ] 生产 `/app/#/push` 验证列表切换与复制
## 验证
侧栏无浏览器;推送页卡片/列表切换;点击名称复制。
@@ -0,0 +1,43 @@
# 审计 — 移除 iframe 内置浏览器
**Changelog**: `docs/changelog/2026-06-11-remove-embedded-browser.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/components/GlobalBrowserPanel.vue` | 删除 |
| `frontend/src/composables/useGlobalBrowser.ts` | 删除 |
| `frontend/src/pages/BrowserPage.vue` | 删除 |
| `server/api/browser.py` | 删除 |
| `server/utils/browser_ui_state.py` | 删除 |
| `tests/test_browser_ui_state.py` | 删除 |
| `frontend/src/App.vue` | 移除 GlobalBrowserPanel、侧栏项 |
| `frontend/src/router/index.ts` | 移除路由 |
| `frontend/src/pages/ServersPage.vue` | 站点 → window.open |
| `server/main.py` | 移除 router |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 无 SSRF 新增 | PASS — 删除 iframe,站点按钮仅客户端 window.open |
| H2 | 无静默吞错 | PASS — 无新增 |
| H3 | 无密钥 | PASS |
| H4 | CUD 审计 | N/A — 删除 API,无新 CUD |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 纯删除 + 外链兜底 |
## DoD
- [x] type-check
- [x] 删除 `/api/browser` 路由
- [ ] 生产部署(待用户批准)
## 验证
侧栏无浏览器;站点按钮新标签打开公网 URL。
@@ -0,0 +1,36 @@
# 推送页服务器名称显示优化
**日期**2026-06-11
## 动机
推送服务器卡片名称过长时被单行截断,且无悬停提示,难以区分相似主机名。
## 变更
- **服务器网格**:名称最多显示 2 行(`line-clamp`);悬停 `title` 展示「名称 + 域名」
- **卡片 / 列表切换**:工具栏图标切换,偏好存 `localStorage``nexus_push_server_view_v1`
- **列表视图**:表格列展示名称、域名、目标路径、勾选与推送状态
- **点击名称复制**:卡片与列表中点击名称复制到剪贴板(`@click.stop`,不触发勾选)
- **推送进度 / 历史 / 预览**:省略号 + `title` 完整名称;历史列宽 120→200
## 涉及文件
- `frontend/src/components/push/PushServerGrid.vue`
- `frontend/src/composables/push/usePushServerViewMode.ts`(新建)
- `frontend/src/components/push/PushProgressList.vue`
- `frontend/src/components/push/PushHistoryTable.vue`
- `frontend/src/components/push/PushPreviewDialog.vue`
- `frontend/src/composables/push/usePushLogs.ts`
## 验证
```bash
cd frontend && npm run type-check
```
推送页:长名称卡片显示两行;悬停见全名;切换列表视图;点击名称 toast「名称已复制」。
## 迁移
无。仅前端构建部署。
@@ -0,0 +1,45 @@
# 移除 iframe 内置浏览器
**日期**2026-06-11
**动机**:用户拒绝 iframe 方案,待独立 Worker + Playwright Chromium 重建;先清理现有实现避免误导。
## 变更摘要
删除全局浮动浏览器、侧栏「浏览器」页、`/api/browser/state` 及关联前后端代码。
## 涉及文件
| 文件 | 动作 |
|------|------|
| `frontend/src/components/GlobalBrowserPanel.vue` | 删除 |
| `frontend/src/composables/useGlobalBrowser.ts` | 删除 |
| `frontend/src/pages/BrowserPage.vue` | 删除 |
| `server/api/browser.py` | 删除 |
| `server/utils/browser_ui_state.py` | 删除 |
| `tests/test_browser_ui_state.py` | 删除 |
| `frontend/src/App.vue` | 移除挂载与侧栏入口 |
| `frontend/src/router/index.ts` | 移除 `/browser` |
| `frontend/src/pages/ServersPage.vue` | 「站点」改为新标签打开 |
| `server/main.py` | 注销 browser 路由 |
**保留**`frontend/src/utils/browserUrl.ts`(域名 → https URL,供「站点」外链)
## 迁移 / 重启
- 无需 DB 迁移;`admin_ui_preferences``embedded_browser` 上下文可留空或手动清理
- 需重启 API + 前端构建部署
## 验证
```bash
cd frontend && npm run type-check
pytest tests/ -q --ignore=tests/integration # 或 bash scripts/local_verify.sh
```
- 侧栏无「浏览器」
- `#/browser` 不再存在(404/回仪表盘)
- 服务器列表「站点」在新标签打开 `https://{domain}`
## 后续
服务端远程浏览器见 `docs/design/plans/2026-06-11-browser-worker-server-procurement.md`
-5
View File
@@ -223,8 +223,6 @@
{{ snackbar.text }}
</v-snackbar>
<GlobalBrowserPanel v-if="auth.isLoggedIn" :show-launcher="auth.isLoggedIn" />
</v-app>
</template>
@@ -249,8 +247,6 @@ import {
} from '@/composables/useScriptSubmitToast'
import { clearCachedQueries } from '@/composables/useCachedQuery'
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
import GlobalBrowserPanel from '@/components/GlobalBrowserPanel.vue'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
import { api } from '@/api'
@@ -347,7 +343,6 @@ const opsItems = [
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
{ to: '/servers', title: '服务器', icon: 'mdi-server' },
{ to: '/terminal', title: '终端', icon: 'mdi-console' },
{ to: '/browser', title: '浏览器', icon: 'mdi-web' },
{ to: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' },
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' },
@@ -1,543 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="panelOpen && tabs.length"
class="global-browser-float"
:class="{ 'global-browser-float--maximized': maximized }"
:style="maximized ? undefined : panelStyle"
>
<v-card border rounded="lg" class="global-browser-panel d-flex flex-column h-100">
<div
class="global-browser-toolbar d-flex align-center flex-shrink-0"
:class="{ 'global-browser-toolbar--draggable': !maximized }"
@mousedown="onToolbarMouseDown"
>
<div
v-if="!maximized"
class="global-browser-drag-strip flex-shrink-0"
title="拖动"
@mousedown="onToolbarMouseDown"
>
<v-icon size="16" class="text-disabled">mdi-drag</v-icon>
</div>
<v-tabs
:model-value="activeTabId ?? undefined"
density="compact"
show-arrows
class="global-browser-tabs flex-grow-1 min-w-0"
@update:model-value="onTabSelected"
@mousedown.stop
>
<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"
class="ml-1 opacity-60"
density="compact"
@click.stop="closeTab(tab.id)"
/>
</v-tab>
</v-tabs>
<v-btn
size="small"
variant="text"
icon="mdi-plus"
title="新标签"
density="compact"
@mousedown.stop
@click="newEmptyTab"
/>
<v-divider vertical class="mx-1" style="height: 20px; align-self: center" @mousedown.stop />
<v-btn
size="small"
variant="text"
icon="mdi-history"
title="浏览记录"
density="compact"
@mousedown.stop
@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"
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
density="compact"
@mousedown.stop
@click="maximized = !maximized"
/>
<v-btn
size="small"
variant="text"
icon="mdi-window-minimize"
title="最小化(可拖动)"
density="compact"
class="mr-1"
@mousedown.stop
@click="minimizePanel"
/>
</div>
<div class="global-browser-nav d-flex align-center px-2 py-1 flex-shrink-0 border-b">
<v-btn icon="mdi-arrow-left" variant="text" size="small" :disabled="!canGoBack" @click="goBack" />
<v-btn icon="mdi-arrow-right" variant="text" size="small" :disabled="!canGoForward" @click="goForward" />
<v-btn icon="mdi-refresh" variant="text" size="small" :disabled="!activeTab?.url" @click="refreshActive" />
<v-text-field
v-model="addressDraft"
density="compact"
variant="outlined"
hide-details
placeholder="https://example.com"
prepend-inner-icon="mdi-web"
class="global-browser-url mx-2"
@keydown.enter.prevent="onGo"
/>
<v-btn color="primary" variant="flat" size="small" class="text-none" @click="onGo">打开</v-btn>
<v-btn
icon="mdi-open-in-new"
variant="text"
size="small"
:disabled="!activeTab?.url"
@click="openExternal"
/>
</div>
<v-alert
v-if="lastError"
type="warning"
density="compact"
variant="tonal"
class="ma-2 mb-0 flex-shrink-0"
closable
@click:close="lastError = null"
>
{{ lastError }}
</v-alert>
<div class="global-browser-main d-flex flex-grow-1 min-h-0">
<aside v-if="showHistory" class="global-browser-history flex-shrink-0 border-e">
<div class="text-caption font-weight-medium px-3 py-2">浏览记录已同步账号</div>
<v-list density="compact" nav class="py-0 global-browser-history-list">
<v-list-item
v-for="(visit, idx) in visits"
:key="`${visit.at}-${idx}`"
:title="visit.title"
:subtitle="visit.url"
@click="openFromHistory(visit.url)"
/>
<v-list-item v-if="!visits.length" title="暂无记录" disabled />
</v-list>
</aside>
<div class="global-browser-content flex-grow-1 d-flex flex-column min-w-0 min-h-0">
<div
v-if="!activeTab?.url"
class="flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis pa-4"
>
<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 引擎中渲染
</p>
</div>
<template v-else>
<iframe
:key="activeTab.frameKey"
:src="activeTab.url"
class="global-browser-frame flex-grow-1"
title="内置浏览器"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"
referrerpolicy="no-referrer-when-downgrade"
/>
<p class="text-caption text-medium-emphasis px-2 py-1 flex-shrink-0 global-browser-hint">
若空白可能禁止 iframe 嵌入 请用新标签打开
</p>
</template>
</div>
</div>
<div
v-if="!maximized"
class="global-browser-resize"
title="拖动调整大小"
@mousedown.stop="onResizeStart"
/>
</v-card>
</div>
<!-- Minimized draggable floating bar (any corner) -->
<div
v-if="minimized && tabs.length"
class="global-browser-mini-float"
:style="miniPanelStyle"
@mousedown="onMiniFloatMouseDown"
>
<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"
@dblclick.stop="openPanel"
>
<div
class="global-browser-drag-strip global-browser-drag-strip--mini flex-shrink-0"
title="拖动"
@mousedown.stop="onMiniDragStart"
>
<v-icon size="16" class="text-disabled">mdi-drag</v-icon>
</div>
<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 global-browser-mini-title"
@click="onMiniTitleClick"
>
{{ activeTab ? tabLabel(activeTab) : '浏览器' }}
</span>
<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"
class="global-browser-launcher"
color="primary"
icon="mdi-web"
size="large"
elevation="4"
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">
import { onMounted, ref, watch } from 'vue'
import { useFloatingPanel, type FloatRect } from '@/composables/useFloatingPanel'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
defineProps<{ showLauncher: boolean }>()
const showHistory = ref(false)
const showRestartConfirm = ref(false)
const maximized = ref(false)
const {
panelOpen,
minimized,
lastError,
addressDraft,
visits,
tabs,
activeTabId,
activeTab,
panelRect,
minimizedRect,
canGoBack,
canGoForward,
tabLabel,
openPanel,
minimizePanel,
restartBrowser,
navigate,
refreshActive,
goBack,
goForward,
openExternal,
switchTab,
closeTab,
newEmptyTab,
openFromHistory,
savePanelRect,
saveMinimizedRect,
bootstrap,
} = useGlobalBrowser()
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',
panelFallback,
)
const {
rect: miniRect,
panelStyle: miniPanelStyle,
startDrag: startMiniDrag,
} = useFloatingPanel('nexus_global_browser_mini_rect_v1', defaultMiniRect(), {
minW: 200,
minH: 44,
maxW: 520,
maxH: 56,
})
let miniDragMoved = false
let suppressMiniTitleClick = false
function seedRectFromServer() {
if (panelRect.value) {
rect.value = { ...panelRect.value }
} else {
centerOnScreen()
}
if (minimizedRect.value) {
miniRect.value = { ...minimizedRect.value }
} else {
miniRect.value = defaultMiniRect()
}
}
onMounted(async () => {
await bootstrap()
seedRectFromServer()
})
watch(rect, (value) => {
savePanelRect({ ...value })
}, { deep: true })
watch(miniRect, (value) => {
saveMinimizedRect({ ...value })
}, { deep: true })
function isDragBlockedTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return true
return Boolean(target.closest('button, .v-tab, .v-btn, input, a, .v-field, .v-chip'))
}
function onToolbarMouseDown(e: MouseEvent) {
if (maximized.value) return
if (isDragBlockedTarget(e.target)) return
e.preventDefault()
startDrag(e)
}
function onMiniDragStart(e: MouseEvent) {
if (e.button !== 0) return
e.preventDefault()
miniDragMoved = false
const originX = e.clientX
const originY = e.clientY
const onMove = (ev: MouseEvent) => {
if (Math.abs(ev.clientX - originX) + Math.abs(ev.clientY - originY) > 4) {
miniDragMoved = true
}
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
suppressMiniTitleClick = miniDragMoved
if (miniDragMoved) {
window.setTimeout(() => {
suppressMiniTitleClick = false
}, 0)
}
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
startMiniDrag(e)
}
function onMiniFloatMouseDown(e: MouseEvent) {
if (e.button !== 0) return
if (isDragBlockedTarget(e.target)) return
if ((e.target as HTMLElement).closest('.global-browser-drag-strip')) return
e.preventDefault()
onMiniDragStart(e)
}
function onResizeStart(e: MouseEvent) {
startResize(e)
}
function onTabSelected(id: unknown) {
if (typeof id === 'string') switchTab(id)
}
function onGo() {
navigate(addressDraft.value)
}
function onLauncherClick() {
if (tabs.value.length) {
openPanel()
return
}
newEmptyTab()
}
function onMiniTitleClick() {
if (suppressMiniTitleClick) return
openPanel()
}
function onRestart() {
showRestartConfirm.value = true
}
function confirmRestart() {
showRestartConfirm.value = false
restartBrowser()
}
</script>
<style scoped>
.global-browser-float,
.global-browser-mini-float {
position: fixed;
z-index: 2350;
display: flex;
flex-direction: column;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
touch-action: none;
}
.global-browser-float--maximized {
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
left: 0 !important;
top: 0 !important;
}
.global-browser-panel,
.global-browser-mini-panel {
overflow: hidden;
position: relative;
height: 100%;
background: rgb(var(--v-theme-surface));
}
.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: default;
user-select: none;
}
.global-browser-drag-strip {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 40px;
margin-left: 4px;
cursor: move;
touch-action: none;
}
.global-browser-drag-strip--mini {
width: 24px;
height: 100%;
margin-left: 0;
margin-right: 4px;
}
.global-browser-mini-title {
cursor: pointer;
}
.global-browser-nav {
background: rgb(var(--v-theme-surface));
}
.global-browser-url {
flex: 1 1 auto;
min-width: 100px;
}
.global-browser-main {
min-height: 0;
}
.global-browser-history {
width: 280px;
max-width: 40%;
background: rgb(var(--v-theme-surface));
}
.global-browser-history-list {
max-height: 100%;
overflow-y: auto;
}
.global-browser-frame {
width: 100%;
min-height: 0;
border: 0;
background: rgb(var(--v-theme-surface));
}
.global-browser-hint {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.global-browser-resize {
position: absolute;
right: 0;
bottom: 0;
width: 18px;
height: 18px;
cursor: nwse-resize;
z-index: 3;
background: linear-gradient(
135deg,
transparent 50%,
rgba(var(--v-theme-on-surface), 0.25) 50%
);
}
.global-browser-launcher {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 2340;
}
</style>
@@ -63,6 +63,12 @@
density="comfortable"
@update:page="$emit('update:page', $event); $emit('page-change')"
>
<template #item.server_name="{ item }">
<span
class="text-body-2 push-history-server-name"
:title="item.server_name ?? ''"
>{{ item.server_name }}</span>
</template>
<template #item.status="{ item }">
<v-chip :color="logStatusColor(item.status)" size="x-small" variant="tonal" label border="sm">
{{ logStatusLabel(item.status) }}
@@ -145,3 +151,14 @@ defineEmits<{
diagnose: [item: PushItem]
}>()
</script>
<style scoped>
.push-history-server-name {
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
</style>
@@ -39,7 +39,9 @@
</thead>
<tbody>
<tr v-for="row in results" :key="row.server_id">
<td>{{ row.server_name }}</td>
<td>
<span class="push-preview-server-name" :title="row.server_name">{{ row.server_name }}</span>
</td>
<td>{{ row.stats?.files_total ?? '—' }}</td>
<td>{{ row.stats?.files_transferred ?? '—' }}</td>
<td>{{ row.stats ? formatSize(row.stats.transfer_size_bytes ?? 0) : '—' }}</td>
@@ -89,3 +91,14 @@ defineEmits<{
'confirm-push': []
}>()
</script>
<style scoped>
.push-preview-server-name {
display: inline-block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
</style>
@@ -47,7 +47,10 @@
</span>
</div>
<v-list density="compact">
<v-list-item v-for="s in items" :key="s.id" :title="s.name">
<v-list-item v-for="s in items" :key="s.id">
<template #title>
<span class="push-progress-name" :title="s.name">{{ s.name }}</span>
</template>
<template #append>
<v-icon v-if="s.status === 'pending'" color="grey">mdi-clock-outline</v-icon>
<v-icon v-else-if="s.status === 'running'" color="blue" class="mdi-spin">mdi-loading</v-icon>
@@ -85,3 +88,12 @@ defineEmits<{
'retry-all': []
}>()
</script>
<style scoped>
.push-progress-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+169 -9
View File
@@ -1,12 +1,12 @@
<template>
<v-card elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="d-flex align-center">
<v-card-title class="d-flex align-center flex-wrap ga-2">
<v-checkbox
:model-value="selectAll"
label="全选"
density="compact"
hide-details
class="mr-4"
class="mr-2"
@update:model-value="$emit('toggle-all')"
/>
<v-text-field
@@ -28,6 +28,18 @@
title="刷新在线状态"
@click="$emit('refresh')"
/>
<v-spacer />
<v-btn-toggle
v-model="viewMode"
mandatory
density="compact"
color="primary"
variant="outlined"
divided
>
<v-btn value="grid" size="small" icon="mdi-view-grid" title="卡片视图" />
<v-btn value="list" size="small" icon="mdi-view-list" title="列表视图" />
</v-btn-toggle>
</v-card-title>
<v-divider />
<v-card-text class="pa-2">
@@ -52,22 +64,31 @@
({{ group.servers.filter(s => selectedIds.has(s.id)).length }}/{{ group.servers.length }})
</span>
</div>
<v-row dense>
<!-- 卡片视图 -->
<v-row v-if="viewMode === 'grid'" dense>
<v-col v-for="s in group.servers" :key="s.id" cols="6" sm="4" md="3" lg="2">
<v-card
:color="selectedIds.has(s.id) ? 'primary' : undefined"
:variant="selectedIds.has(s.id) ? 'tonal' : 'outlined'"
rounded="lg"
class="pa-3 cursor-pointer"
class="pa-3 cursor-pointer push-server-card"
@click="$emit('toggle-server', s.id)"
>
<div class="d-flex align-center ga-2">
<v-icon :color="serverOnlineColor(s.is_online)" size="12">mdi-circle</v-icon>
<span class="text-body-2 font-weight-medium text-truncate">{{ s.name }}</span>
<div class="d-flex align-center ga-2 min-w-0">
<v-icon :color="serverOnlineColor(s.is_online)" size="12" class="flex-shrink-0">mdi-circle</v-icon>
<span
class="text-body-2 font-weight-medium push-server-card__name push-server-copy-name"
:title="`${serverLabelTitle(s)}\n点击复制名称`"
@click.stop="copyServerName(s.name)"
>{{ s.name }}</span>
</div>
<div class="text-caption text-medium-emphasis text-truncate">{{ s.domain }}</div>
<div
class="text-caption text-medium-emphasis text-truncate"
class="text-caption text-medium-emphasis push-server-card__line"
:title="s.domain || ''"
>{{ s.domain }}</div>
<div
class="text-caption text-medium-emphasis push-server-card__line"
:title="s.target_path || '未配置目标路径'"
>
{{ s.target_path?.trim() || '未配置' }}
@@ -86,6 +107,67 @@
</v-card>
</v-col>
</v-row>
<!-- 列表视图 -->
<v-table v-else density="compact" class="push-server-list rounded-lg border">
<thead>
<tr>
<th class="push-server-list__col-check" />
<th class="push-server-list__col-online" />
<th>名称</th>
<th>域名</th>
<th>目标路径</th>
<th class="push-server-list__col-status">状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in group.servers"
:key="s.id"
class="push-server-list__row cursor-pointer"
:class="{ 'push-server-list__row--selected': selectedIds.has(s.id) }"
@click="$emit('toggle-server', s.id)"
>
<td @click.stop>
<v-checkbox
:model-value="selectedIds.has(s.id)"
density="compact"
hide-details
@update:model-value="$emit('toggle-server', s.id)"
/>
</td>
<td>
<v-icon :color="serverOnlineColor(s.is_online)" size="12">mdi-circle</v-icon>
</td>
<td class="push-server-list__name-cell">
<span
class="push-server-copy-name"
:title="`${s.name}\n点击复制名称`"
@click.stop="copyServerName(s.name)"
>{{ s.name }}</span>
</td>
<td class="text-caption text-medium-emphasis push-server-list__ellipsis" :title="s.domain || ''">
{{ s.domain || '—' }}
</td>
<td class="text-caption text-medium-emphasis push-server-list__ellipsis" :title="s.target_path || '未配置'">
{{ s.target_path?.trim() || '未配置' }}
</td>
<td>
<v-chip
v-if="pushStatus[s.id]"
:color="pushStatusChipColor(pushStatus[s.id])"
size="x-small"
variant="tonal"
label
border="sm"
>
{{ pushStatus[s.id] }}
</v-chip>
<span v-else class="text-caption text-medium-emphasis"></span>
</td>
</tr>
</tbody>
</v-table>
</div>
</template>
</v-card-text>
@@ -94,6 +176,8 @@
<script setup lang="ts">
import { pushStatusChipColor, serverOnlineColor } from '@/composables/push/labels'
import { usePushServerViewMode } from '@/composables/push/usePushServerViewMode'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PushServerItem } from '@/composables/push/types'
defineProps<{
@@ -113,4 +197,80 @@ defineEmits<{
'toggle-category': [category: string]
'toggle-server': [id: number]
}>()
const { viewMode } = usePushServerViewMode()
const snackbar = useSnackbar()
function serverLabelTitle(s: PushServerItem): string {
const parts = [s.name]
if (s.domain?.trim()) parts.push(s.domain.trim())
return parts.join('\n')
}
async function copyServerName(name: string) {
const text = name.trim()
if (!text) return
try {
await navigator.clipboard.writeText(text)
snackbar('名称已复制')
} catch {
snackbar('复制失败', 'error')
}
}
</script>
<style scoped>
.push-server-card__name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-word;
min-width: 0;
flex: 1 1 auto;
line-height: 1.25;
}
.push-server-card__line {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.push-server-copy-name {
cursor: copy;
}
.push-server-copy-name:hover {
text-decoration: underline;
text-decoration-style: dotted;
}
.push-server-list__col-check {
width: 44px;
}
.push-server-list__col-online {
width: 28px;
}
.push-server-list__col-status {
width: 96px;
}
.push-server-list__row--selected {
background: rgba(var(--v-theme-primary), 0.08);
}
.push-server-list__name-cell {
max-width: 0;
width: 32%;
word-break: break-word;
}
.push-server-list__ellipsis {
max-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.push-server-list :deep(th:nth-child(4)),
.push-server-list :deep(td:nth-child(4)) {
width: 24%;
}
.push-server-list :deep(th:nth-child(5)),
.push-server-list :deep(td:nth-child(5)) {
width: 28%;
}
</style>
+1 -1
View File
@@ -24,7 +24,7 @@ const LOG_SYNC_MODE_OPTIONS = [
const LOG_HEADERS = [
{ title: '时间', key: 'started_at', width: 170 },
{ title: '服务器', key: 'server_name', width: 120 },
{ title: '服务器', key: 'server_name', width: 200 },
{ title: '状态', key: 'status', width: 88 },
{ title: '模式', key: 'sync_mode', width: 88 },
{ title: '源路径', key: 'source_path' },
@@ -0,0 +1,28 @@
import { ref, watch } from 'vue'
export type PushServerViewMode = 'grid' | 'list'
const LS_KEY = 'nexus_push_server_view_v1'
function readStoredViewMode(): PushServerViewMode {
try {
const raw = localStorage.getItem(LS_KEY)
return raw === 'list' ? 'list' : 'grid'
} catch {
return 'grid'
}
}
const viewMode = ref<PushServerViewMode>(readStoredViewMode())
watch(viewMode, (mode) => {
try {
localStorage.setItem(LS_KEY, mode)
} catch {
/* ignore quota / private mode */
}
})
export function usePushServerViewMode() {
return { viewMode }
}
@@ -1,384 +0,0 @@
/**
* Global floating browser — persists tabs/history to MySQL via /api/browser/state.
* Singleton module state survives route changes.
*/
import { computed, ref } from 'vue'
import { http } from '@/api'
import { normalizeBrowserUrl } from '@/utils/browserUrl'
import type { FloatRect } from '@/composables/useFloatingPanel'
const LEGACY_HISTORY_KEY = 'nexus_browser_history_v1'
const SAVE_DEBOUNCE_MS = 700
export interface BrowserVisit {
url: string
title: string
at: string
}
export interface BrowserTab {
id: string
url: string
title: string
stack: string[]
stackIndex: number
frameKey: number
}
export interface BrowserStateResponse {
url_history: string[]
visits: BrowserVisit[]
tabs: Array<{
id: string
url: string
title?: string
stack?: string[]
stack_index?: number
}>
active_tab_id: string | null
minimized: boolean
rect: FloatRect | null
minimized_rect: FloatRect | null
}
const bootstrapped = ref(false)
const saving = ref(false)
const panelOpen = ref(false)
const minimized = ref(false)
const maximized = ref(false)
const lastError = ref<string | null>(null)
const addressDraft = ref('')
const urlHistory = ref<string[]>([])
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
function tabLabel(tab: BrowserTab): string {
try {
return tab.title || new URL(tab.url).hostname
} catch {
return tab.title || tab.url
}
}
function newTabId(): string {
return crypto.randomUUID()
}
function serializeTabs(): BrowserStateResponse['tabs'] {
return tabs.value.map((t) => ({
id: t.id,
url: t.url,
title: t.title,
stack: t.stack,
stack_index: t.stackIndex,
}))
}
function applyServerState(data: BrowserStateResponse) {
urlHistory.value = data.url_history || []
visits.value = (data.visits || []).map((v) => ({
url: v.url,
title: v.title || v.url,
at: v.at,
}))
tabs.value = (data.tabs || []).map((t) => ({
id: t.id,
url: t.url,
title: t.title || t.url,
stack: t.stack?.length ? t.stack : [t.url],
stackIndex: typeof t.stack_index === 'number' ? t.stack_index : (t.stack?.length ?? 1) - 1,
frameKey: 0,
}))
activeTabId.value = data.active_tab_id
if (!activeTabId.value && tabs.value.length) {
activeTabId.value = tabs.value[0]!.id
}
minimized.value = Boolean(data.minimized)
panelRect.value = data.rect
minimizedRect.value = data.minimized_rect
panelOpen.value = tabs.value.length > 0 && !minimized.value
syncAddressFromActive()
}
function syncAddressFromActive() {
const tab = activeTab.value
addressDraft.value = tab?.url ?? ''
}
const activeTab = computed(() => tabs.value.find((t) => t.id === activeTabId.value) ?? null)
const isActive = computed(() => tabs.value.length > 0 || panelOpen.value)
const canGoBack = computed(() => {
const tab = activeTab.value
return tab ? tab.stackIndex > 0 : false
})
const canGoForward = computed(() => {
const tab = activeTab.value
return tab ? tab.stackIndex < tab.stack.length - 1 : false
})
function scheduleSave(extra?: { recordUrl?: string; recordTitle?: string }) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
void persistState(extra)
}, SAVE_DEBOUNCE_MS)
}
async function persistState(extra?: { recordUrl?: string; recordTitle?: string }) {
saving.value = true
try {
const body: Record<string, unknown> = {
tabs: serializeTabs(),
active_tab_id: activeTabId.value,
minimized: minimized.value,
rect: panelRect.value,
minimized_rect: minimizedRect.value,
}
if (extra?.recordUrl) {
body.record_url = extra.recordUrl
if (extra.recordTitle) body.record_title = extra.recordTitle
} else {
body.url_history = urlHistory.value
body.visits = visits.value
}
const res = await http.put<BrowserStateResponse>('/browser/state', body)
applyServerState(res)
} catch {
/* keep local state */
} finally {
saving.value = false
}
}
async function migrateLegacyHistory() {
try {
const raw = sessionStorage.getItem(LEGACY_HISTORY_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return
const urls = parsed.filter((u): u is string => typeof u === 'string' && !!normalizeBrowserUrl(u))
if (!urls.length) return
for (const url of [...urls].reverse()) {
const normalized = normalizeBrowserUrl(url)
if (normalized) {
await persistState({ recordUrl: normalized, recordTitle: normalized })
}
}
sessionStorage.removeItem(LEGACY_HISTORY_KEY)
} catch {
/* ignore */
}
}
async function bootstrap() {
if (bootstrapped.value) return
try {
const res = await http.get<BrowserStateResponse>('/browser/state')
applyServerState(res)
if (!visits.value.length && !urlHistory.value.length) {
await migrateLegacyHistory()
}
} catch {
/* offline */
} finally {
bootstrapped.value = true
}
}
function openPanel() {
panelOpen.value = true
minimized.value = false
scheduleSave()
}
function minimizePanel() {
minimized.value = true
panelOpen.value = false
scheduleSave()
}
/** 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()
}
function openUrl(input: string, opts?: { title?: string; newTab?: boolean }) {
const normalized = normalizeBrowserUrl(input)
if (!normalized) {
lastError.value = '请输入有效的 http 或 https 地址'
return false
}
lastError.value = null
if (opts?.newTab || !tabs.value.length || !activeTab.value) {
const tab: BrowserTab = {
id: newTabId(),
url: normalized,
title: opts?.title || normalized,
stack: [normalized],
stackIndex: 0,
frameKey: 0,
}
tabs.value = [...tabs.value, tab].slice(-12)
activeTabId.value = tab.id
} else {
const tab = activeTab.value
const stack = tab.stack.slice(0, tab.stackIndex + 1)
if (!stack.length || stack[stack.length - 1] !== normalized) stack.push(normalized)
tab.url = normalized
if (opts?.title) tab.title = opts.title
tab.stack = stack
tab.stackIndex = stack.length - 1
tab.frameKey += 1
}
addressDraft.value = normalized
openPanel()
scheduleSave({ recordUrl: normalized, recordTitle: opts?.title })
return true
}
function navigate(input: string) {
return openUrl(input)
}
function refreshActive() {
const tab = activeTab.value
if (!tab) return
tab.frameKey += 1
}
function goBack() {
const tab = activeTab.value
if (!tab || tab.stackIndex <= 0) return
tab.stackIndex -= 1
tab.url = tab.stack[tab.stackIndex]!
tab.frameKey += 1
addressDraft.value = tab.url
scheduleSave()
}
function goForward() {
const tab = activeTab.value
if (!tab || tab.stackIndex >= tab.stack.length - 1) return
tab.stackIndex += 1
tab.url = tab.stack[tab.stackIndex]!
tab.frameKey += 1
addressDraft.value = tab.url
scheduleSave()
}
function openExternal() {
const tab = activeTab.value
if (!tab) return
window.open(tab.url, '_blank', 'noopener,noreferrer')
}
function switchTab(id: string) {
activeTabId.value = id
syncAddressFromActive()
scheduleSave()
}
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)
tabs.value = next
if (activeTabId.value === id) {
activeTabId.value = next[idx]?.id ?? next[idx - 1]?.id ?? null
}
syncAddressFromActive()
scheduleSave()
}
function newEmptyTab() {
const tab: BrowserTab = {
id: newTabId(),
url: '',
title: '新标签',
stack: [],
stackIndex: -1,
frameKey: 0,
}
tabs.value = [...tabs.value, tab].slice(-12)
activeTabId.value = tab.id
addressDraft.value = ''
openPanel()
scheduleSave()
}
function openFromHistory(url: string) {
openUrl(url)
}
function savePanelRect(rect: FloatRect) {
panelRect.value = rect
scheduleSave()
}
function saveMinimizedRect(rect: FloatRect) {
minimizedRect.value = rect
scheduleSave()
}
export function useGlobalBrowser() {
return {
bootstrapped,
saving,
panelOpen,
minimized,
maximized,
lastError,
addressDraft,
urlHistory,
visits,
tabs,
activeTabId,
activeTab,
panelRect,
minimizedRect,
isActive,
canGoBack,
canGoForward,
tabLabel,
bootstrap,
openPanel,
minimizePanel,
restartBrowser,
openUrl,
navigate,
refreshActive,
goBack,
goForward,
openExternal,
switchTab,
closeTab,
newEmptyTab,
openFromHistory,
savePanelRect,
saveMinimizedRect,
scheduleSave,
}
}
-29
View File
@@ -1,29 +0,0 @@
<template>
<div class="pa-6 text-center text-medium-emphasis">正在打开浏览器</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
const route = useRoute()
const router = useRouter()
const browser = useGlobalBrowser()
onMounted(async () => {
await browser.bootstrap()
const url = typeof route.query.url === 'string' ? route.query.url : ''
if (url) {
browser.openUrl(url)
} else if (!browser.tabs.value.length) {
browser.newEmptyTab()
} else {
browser.openPanel()
}
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
? route.query.redirect
: '/'
router.replace(redirect)
})
</script>
+1 -3
View File
@@ -672,7 +672,6 @@ import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServ
import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue'
@@ -763,7 +762,6 @@ const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const router = useRouter()
const globalBrowser = useGlobalBrowser()
const route = useRoute()
const showCredentialsDialog = ref(false)
@@ -1434,7 +1432,7 @@ function openBrowser(item: ServerApiItem) {
snackbar('该服务器无有效域名', 'warning')
return
}
globalBrowser.openUrl(url, { title: item.name })
window.open(url, '_blank', 'noopener,noreferrer')
}
function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
-1
View File
@@ -5,7 +5,6 @@ const routes = [
{ path: '/', name: 'Dashboard', component: () => import('@/pages/DashboardPage.vue') },
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
{ path: '/browser', name: 'Browser', component: () => import('@/pages/BrowserPage.vue') },
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
-87
View File
@@ -1,87 +0,0 @@
"""Embedded browser UI preferences API."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from server.api.auth_jwt import get_current_admin
from server.api.dependencies import get_db
from server.domain.models import Admin
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.browser_ui_state import (
BROWSER_UI_CONTEXT,
merge_browser_state,
parse_browser_state,
record_visit,
)
router = APIRouter(prefix="/api/browser", tags=["browser"])
class BrowserVisitRecord(BaseModel):
url: str
title: str | None = None
class BrowserTabState(BaseModel):
id: str
url: str
title: str | None = None
stack: list[str] | None = None
stack_index: int | None = None
class BrowserPanelRect(BaseModel):
x: float
y: float
w: float
h: float
class BrowserStateUpdate(BaseModel):
url_history: list[str] | None = None
visits: list[dict] | None = None
tabs: list[BrowserTabState] | None = None
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
@router.get("/state")
async def get_browser_state(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = AdminUiPreferenceRepositoryImpl(db)
raw = await repo.get(admin.id, BROWSER_UI_CONTEXT)
return parse_browser_state(raw)
@router.put("/state")
async def put_browser_state(
payload: BrowserStateUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = AdminUiPreferenceRepositoryImpl(db)
current = parse_browser_state(await repo.get(admin.id, BROWSER_UI_CONTEXT))
incoming = payload.model_dump(exclude_none=True)
if payload.tabs is not None:
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:
merged = record_visit(merged, payload.record_url, payload.record_title)
await repo.upsert(admin.id, BROWSER_UI_CONTEXT, merged)
return merged
-5
View File
@@ -687,11 +687,6 @@ app.include_router(search_router)
# Terminal Quick Commands
app.include_router(terminal_router)
# Embedded browser UI state
from server.api.browser import router as browser_router # noqa: E402
app.include_router(browser_router)
# ── Custom 404: return blank HTML to hide backend identity ──
@app.exception_handler(StarletteHTTPException)
-164
View File
@@ -1,164 +0,0 @@
"""Embedded browser UI state — history, tabs, panel layout."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
BROWSER_UI_CONTEXT = "embedded_browser"
MAX_URL_HISTORY = 128
MAX_VISIT_LOG = 512
MAX_TABS = 12
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec="seconds")
def _is_safe_url(url: str) -> bool:
try:
parsed = urlparse(url.strip())
except Exception:
return False
if parsed.scheme not in ("http", "https"):
return False
if parsed.username or parsed.password:
return False
return bool(parsed.netloc)
def _hostname(url: str) -> str:
try:
return urlparse(url).hostname or url
except Exception:
return url
def empty_browser_state() -> dict[str, Any]:
return {
"url_history": [],
"visits": [],
"tabs": [],
"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):
return base
url_history = [
u for u in raw.get("url_history") or []
if isinstance(u, str) and _is_safe_url(u)
][:MAX_URL_HISTORY]
visits: list[dict[str, str]] = []
for item in raw.get("visits") or []:
if not isinstance(item, dict):
continue
url = item.get("url")
if not isinstance(url, str) or not _is_safe_url(url):
continue
visits.append({
"url": url,
"title": str(item.get("title") or _hostname(url))[:200],
"at": str(item.get("at") or _utcnow_iso()),
})
visits = visits[:MAX_VISIT_LOG]
tabs: list[dict[str, Any]] = []
for item in raw.get("tabs") or []:
if not isinstance(item, dict):
continue
tab_id = item.get("id")
url = item.get("url")
if not isinstance(tab_id, str) or not tab_id.strip():
continue
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)
] or [url]
stack_index = item.get("stack_index", len(stack) - 1)
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.strip(),
"title": str(item.get("title") or (_hostname(url) if url.strip() else "新标签"))[:200],
"stack": stack[-64:],
"stack_index": stack_index,
})
tabs = tabs[:MAX_TABS]
active_tab_id = raw.get("active_tab_id")
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 = _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"))
return {
"url_history": url_history,
"visits": visits,
"tabs": tabs,
"active_tab_id": active_tab_id,
"minimized": minimized,
"rect": rect,
"minimized_rect": minimized_rect,
}
def merge_browser_state(current: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
"""Merge client state; incoming wins for tabs/active/minimized/rect."""
parsed = parse_browser_state({**current, **incoming})
if incoming.get("url_history") is not None:
parsed["url_history"] = parse_browser_state({"url_history": incoming["url_history"]})["url_history"]
if incoming.get("visits") is not None:
parsed["visits"] = parse_browser_state({"visits": incoming["visits"]})["visits"]
return parsed
def record_visit(state: dict[str, Any], url: str, title: str | None = None) -> dict[str, Any]:
if not _is_safe_url(url):
return state
next_state = parse_browser_state(state)
hist = [url] + [u for u in next_state["url_history"] if u != url]
next_state["url_history"] = hist[:MAX_URL_HISTORY]
visit = {
"url": url,
"title": (title or _hostname(url))[:200],
"at": _utcnow_iso(),
}
visits = [visit] + next_state["visits"]
next_state["visits"] = visits[:MAX_VISIT_LOG]
return next_state