feat: 全局浮动浏览器、记录持久化与角落快捷入口
浮动面板可最小化至右下角、左下角随时展开;标签与浏览记录写入 MySQL,切换菜单保持活动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# 审计 — 全局浮动浏览器
|
||||
|
||||
## 范围(文件清单)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/utils/browser_ui_state.py` | URL 校验、visit 上限 |
|
||||
| `server/api/browser.py` | `/api/browser/state` |
|
||||
| `frontend/src/composables/useGlobalBrowser.ts` | 全局状态 |
|
||||
| `frontend/src/components/GlobalBrowserPanel.vue` | 浮动面板 |
|
||||
| `tests/test_browser_ui_state.py` | 单测 |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 无 SSRF | PASS — 无服务端 fetch |
|
||||
| H2 | URL 白名单 | PASS — http/https only |
|
||||
| H3 | 按管理员隔离 | PASS — admin_ui_preferences |
|
||||
| H4 | iframe sandbox | PASS |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] pytest
|
||||
- [x] type-check
|
||||
- [x] changelog
|
||||
|
||||
## 验证
|
||||
|
||||
浮动打开 → 最小化右下角 → 左下角展开 → 切换菜单 iframe 仍在。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 2026-06-11 全局浮动浏览器与记录持久化
|
||||
|
||||
## 摘要
|
||||
|
||||
内置浏览器改为 **全局浮动面板**:可拖动/缩放、最小化到**右下角**、**左下角**常驻按钮随时展开;切换侧栏页面时保持活动。浏览记录、标签、窗口位置同步至 MySQL(`admin_ui_preferences`)。
|
||||
|
||||
## 动机
|
||||
|
||||
用户需要在运维任意页面内预览站点,最小化后不占全屏,且换页不丢失;历史记录需跨浏览器/清缓存保留。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/utils/browser_ui_state.py` | 状态解析与 visit 记录 |
|
||||
| `server/api/browser.py` | `GET/PUT /api/browser/state` |
|
||||
| `server/main.py` | 注册路由 |
|
||||
| `frontend/src/composables/useGlobalBrowser.ts` | 全局单例状态 |
|
||||
| `frontend/src/components/GlobalBrowserPanel.vue` | 浮动 UI |
|
||||
| `frontend/src/pages/BrowserPage.vue` | 打开全局浏览器并返回 |
|
||||
| `frontend/src/App.vue` | 挂载全局组件 |
|
||||
| `frontend/src/pages/ServersPage.vue` | 「站点」直接打开浮动窗 |
|
||||
| `tests/test_browser_ui_state.py` | 单测 |
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无需新表(复用 `admin_ui_preferences`)
|
||||
- 需 API 重启 + 前端 build
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_browser_ui_state.py -q
|
||||
cd frontend && npm run type-check
|
||||
```
|
||||
|
||||
左下角地球按钮打开;最小化后在右下角;历史按钮可见记录;换账号后记录隔离。
|
||||
|
||||
## 说明
|
||||
|
||||
iframe 在用户当前的 Chrome/Firefox 等引擎中渲染,非独立安装 Firefox;禁止嵌入的站点请用「新标签打开」。
|
||||
+5
-27
@@ -223,6 +223,8 @@
|
||||
{{ snackbar.text }}
|
||||
</v-snackbar>
|
||||
|
||||
<GlobalBrowserPanel v-if="auth.isLoggedIn" :show-launcher="auth.isLoggedIn" />
|
||||
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
@@ -247,6 +249,8 @@ 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'
|
||||
|
||||
@@ -327,8 +331,7 @@ provide('nexusDrawer', drawer)
|
||||
|
||||
const mainLayoutClass = computed(() => ({
|
||||
'nexus-terminal-main': route.path === '/terminal',
|
||||
'nexus-browser-main': route.path === '/browser',
|
||||
'nexus-page-main': route.path !== '/terminal' && route.path !== '/browser' && route.path !== '/login',
|
||||
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
|
||||
}))
|
||||
|
||||
const scriptExecBarOffset = computed(() => {
|
||||
@@ -417,31 +420,6 @@ window.$snackbar = (text: string, color = 'success') => {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v-application .v-main.nexus-browser-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding-top: var(--v-layout-top, 64px) !important;
|
||||
padding-bottom: 0 !important;
|
||||
transition:
|
||||
padding 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
height 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.v-application .v-main.nexus-browser-main > .v-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 仪表盘等业务页:随侧栏 --v-layout-left 缩进,表格可横向滚动 */
|
||||
.v-application .v-main.nexus-page-main {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
<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"
|
||||
>
|
||||
<v-icon v-if="!maximized" size="16" class="ml-2 mr-1 text-disabled" @mousedown.stop>
|
||||
mdi-drag
|
||||
</v-icon>
|
||||
|
||||
<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
|
||||
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="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"
|
||||
@mousedown.stop
|
||||
@click="minimizePanel"
|
||||
/>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
icon="mdi-close"
|
||||
title="关闭浏览器"
|
||||
density="compact"
|
||||
class="mr-1"
|
||||
@mousedown.stop
|
||||
@click="closePanel"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<v-card
|
||||
v-if="minimized && tabs.length"
|
||||
border
|
||||
rounded="lg"
|
||||
class="global-browser-dock-br"
|
||||
>
|
||||
<v-icon icon="mdi-web" size="18" class="mr-2" />
|
||||
<span class="text-caption text-truncate global-browser-dock-label" @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-card>
|
||||
|
||||
<v-btn
|
||||
v-if="showLauncher"
|
||||
class="global-browser-launcher"
|
||||
color="primary"
|
||||
icon="mdi-web"
|
||||
size="large"
|
||||
elevation="4"
|
||||
title="打开 / 展开浏览器"
|
||||
@click="onLauncherClick"
|
||||
/>
|
||||
</Teleport>
|
||||
</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 maximized = ref(false)
|
||||
|
||||
const {
|
||||
panelOpen,
|
||||
minimized,
|
||||
lastError,
|
||||
addressDraft,
|
||||
visits,
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
panelRect,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
tabLabel,
|
||||
openPanel,
|
||||
minimizePanel,
|
||||
closePanel,
|
||||
navigate,
|
||||
refreshActive,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
switchTab,
|
||||
closeTab,
|
||||
newEmptyTab,
|
||||
openFromHistory,
|
||||
savePanelRect,
|
||||
bootstrap,
|
||||
} = useGlobalBrowser()
|
||||
|
||||
const fallback: FloatRect = { x: 80, y: 72, w: 980, h: 640 }
|
||||
const { rect, panelStyle, startDrag, startResize, centerOnScreen } = useFloatingPanel(
|
||||
'nexus_global_browser_rect_v1',
|
||||
fallback,
|
||||
)
|
||||
|
||||
function seedRectFromServer() {
|
||||
if (panelRect.value) {
|
||||
rect.value = { ...panelRect.value }
|
||||
return
|
||||
}
|
||||
centerOnScreen()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await bootstrap()
|
||||
seedRectFromServer()
|
||||
})
|
||||
|
||||
watch(rect, (value) => {
|
||||
savePanelRect({ ...value })
|
||||
}, { deep: true })
|
||||
|
||||
function onToolbarMouseDown(e: MouseEvent) {
|
||||
if (maximized.value) return
|
||||
startDrag(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()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.global-browser-float {
|
||||
position: fixed;
|
||||
z-index: 2350;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.global-browser-float--maximized {
|
||||
inset: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
.global-browser-panel {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.global-browser-toolbar {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
.global-browser-toolbar--draggable {
|
||||
cursor: move;
|
||||
}
|
||||
.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-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;
|
||||
bottom: 16px;
|
||||
z-index: 2340;
|
||||
}
|
||||
</style>
|
||||
@@ -1,115 +0,0 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeBrowserUrl } from '@/utils/browserUrl'
|
||||
|
||||
const HISTORY_KEY = 'nexus_browser_history_v1'
|
||||
const MAX_HISTORY = 32
|
||||
|
||||
function readHistory(): string[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(HISTORY_KEY)
|
||||
const parsed = raw ? (JSON.parse(raw) as unknown) : []
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((u): u is string => typeof u === 'string')
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeHistory(items: string[]) {
|
||||
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(-MAX_HISTORY)))
|
||||
}
|
||||
|
||||
export function useEmbeddedBrowser(initialUrl?: string) {
|
||||
const history = ref<string[]>(readHistory())
|
||||
const historyIndex = ref(-1)
|
||||
const addressDraft = ref('')
|
||||
const frameUrl = ref<string | null>(null)
|
||||
const frameKey = ref(0)
|
||||
const lastError = ref<string | null>(null)
|
||||
|
||||
function pushHistory(url: string) {
|
||||
const list = history.value.slice(0, historyIndex.value + 1)
|
||||
if (list[list.length - 1] !== url) list.push(url)
|
||||
history.value = list
|
||||
historyIndex.value = list.length - 1
|
||||
writeHistory(list)
|
||||
}
|
||||
|
||||
function navigate(input: string, replace = false): boolean {
|
||||
const normalized = normalizeBrowserUrl(input)
|
||||
if (!normalized) {
|
||||
lastError.value = '请输入有效的 http 或 https 地址'
|
||||
return false
|
||||
}
|
||||
lastError.value = null
|
||||
addressDraft.value = normalized
|
||||
frameUrl.value = normalized
|
||||
frameKey.value += 1
|
||||
if (!replace) {
|
||||
pushHistory(normalized)
|
||||
} else if (history.value.length === 0) {
|
||||
history.value = [normalized]
|
||||
historyIndex.value = 0
|
||||
writeHistory(history.value)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!frameUrl.value) return
|
||||
frameKey.value += 1
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (historyIndex.value <= 0) return
|
||||
historyIndex.value -= 1
|
||||
const url = history.value[historyIndex.value]
|
||||
if (!url) return
|
||||
addressDraft.value = url
|
||||
frameUrl.value = url
|
||||
frameKey.value += 1
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
if (historyIndex.value >= history.value.length - 1) return
|
||||
historyIndex.value += 1
|
||||
const url = history.value[historyIndex.value]
|
||||
if (!url) return
|
||||
addressDraft.value = url
|
||||
frameUrl.value = url
|
||||
frameKey.value += 1
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
const canGoBack = computed(() => historyIndex.value > 0)
|
||||
const canGoForward = computed(() => historyIndex.value >= 0 && historyIndex.value < history.value.length - 1)
|
||||
|
||||
function openExternal() {
|
||||
if (!frameUrl.value) return
|
||||
window.open(frameUrl.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
if (initialUrl) {
|
||||
navigate(initialUrl, true)
|
||||
}
|
||||
|
||||
watch(frameUrl, (url) => {
|
||||
if (url) addressDraft.value = url
|
||||
})
|
||||
|
||||
return {
|
||||
addressDraft,
|
||||
frameUrl,
|
||||
frameKey,
|
||||
lastError,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
navigate,
|
||||
refresh,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
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,
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
tabs.value = []
|
||||
activeTabId.value = null
|
||||
panelOpen.value = false
|
||||
minimized.value = false
|
||||
maximized.value = false
|
||||
addressDraft.value = ''
|
||||
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) {
|
||||
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
|
||||
}
|
||||
if (!next.length) {
|
||||
closePanel()
|
||||
return
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
export function useGlobalBrowser() {
|
||||
return {
|
||||
bootstrapped,
|
||||
saving,
|
||||
panelOpen,
|
||||
minimized,
|
||||
maximized,
|
||||
lastError,
|
||||
addressDraft,
|
||||
urlHistory,
|
||||
visits,
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
panelRect,
|
||||
isActive,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
tabLabel,
|
||||
bootstrap,
|
||||
openPanel,
|
||||
minimizePanel,
|
||||
closePanel,
|
||||
openUrl,
|
||||
navigate,
|
||||
refreshActive,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
switchTab,
|
||||
closeTab,
|
||||
newEmptyTab,
|
||||
openFromHistory,
|
||||
savePanelRect,
|
||||
scheduleSave,
|
||||
}
|
||||
}
|
||||
@@ -1,160 +1,29 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-0 d-flex flex-column browser-root">
|
||||
<v-toolbar density="compact" color="surface" class="browser-toolbar border-b">
|
||||
<v-btn
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
:disabled="!canGoBack"
|
||||
aria-label="后退"
|
||||
@click="goBack"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-arrow-right"
|
||||
variant="text"
|
||||
:disabled="!canGoForward"
|
||||
aria-label="前进"
|
||||
@click="goForward"
|
||||
/>
|
||||
<v-btn icon="mdi-refresh" variant="text" :disabled="!frameUrl" aria-label="刷新" @click="refresh" />
|
||||
|
||||
<v-text-field
|
||||
v-model="addressDraft"
|
||||
class="browser-url-field mx-2"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
placeholder="https://example.com"
|
||||
prepend-inner-icon="mdi-web"
|
||||
@keydown.enter.prevent="onGo"
|
||||
/>
|
||||
|
||||
<v-btn color="primary" variant="flat" class="text-none" @click="onGo">打开</v-btn>
|
||||
<v-btn
|
||||
icon="mdi-open-in-new"
|
||||
variant="text"
|
||||
:disabled="!frameUrl"
|
||||
aria-label="新标签打开"
|
||||
@click="openExternal"
|
||||
/>
|
||||
</v-toolbar>
|
||||
|
||||
<v-alert
|
||||
v-if="lastError"
|
||||
type="warning"
|
||||
density="compact"
|
||||
variant="tonal"
|
||||
class="ma-2 mb-0"
|
||||
closable
|
||||
@click:close="clearError"
|
||||
>
|
||||
{{ lastError }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="!frameUrl" class="browser-empty flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis">
|
||||
<v-icon icon="mdi-web" size="64" class="mb-4 opacity-50" />
|
||||
<p class="text-body-1 mb-4">输入地址预览站点,或从服务器列表点击「站点」</p>
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="mdi-server" @click="router.push('/servers')">
|
||||
选择服务器
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-else class="browser-frame-wrap flex-grow-1">
|
||||
<iframe
|
||||
:key="frameKey"
|
||||
:src="frameUrl"
|
||||
class="browser-frame"
|
||||
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="browser-frame-hint text-caption text-medium-emphasis pa-2">
|
||||
若页面空白,可能该站点禁止 iframe 嵌入,请使用右上角「新标签打开」。
|
||||
</p>
|
||||
</div>
|
||||
</v-container>
|
||||
<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 { useEmbeddedBrowser } from '@/composables/useEmbeddedBrowser'
|
||||
import { normalizeBrowserUrl } from '@/utils/browserUrl'
|
||||
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const browser = useGlobalBrowser()
|
||||
|
||||
const initial = typeof route.query.url === 'string' ? route.query.url : ''
|
||||
|
||||
const {
|
||||
addressDraft,
|
||||
frameUrl,
|
||||
frameKey,
|
||||
lastError,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
navigate,
|
||||
refresh,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
} = useEmbeddedBrowser()
|
||||
|
||||
function onGo() {
|
||||
const ok = navigate(addressDraft.value)
|
||||
if (ok && frameUrl.value) {
|
||||
router.replace({ path: '/browser', query: { url: frameUrl.value } }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (initial) {
|
||||
const normalized = normalizeBrowserUrl(initial)
|
||||
if (normalized) navigate(normalized, true)
|
||||
else lastError.value = '链接参数无效,请重新输入地址'
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.browser-root {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.browser-toolbar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.browser-url-field {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.browser-frame-wrap {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.browser-frame {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: var(--v-theme-surface);
|
||||
}
|
||||
|
||||
.browser-frame-hint {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.browser-empty {
|
||||
min-height: 240px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -672,6 +672,7 @@ 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'
|
||||
@@ -745,6 +746,7 @@ const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
|
||||
|
||||
const snackbar = useSnackbar()
|
||||
const router = useRouter()
|
||||
const globalBrowser = useGlobalBrowser()
|
||||
const route = useRoute()
|
||||
const showCredentialsDialog = ref(false)
|
||||
|
||||
@@ -1413,7 +1415,7 @@ function openBrowser(item: ServerApiItem) {
|
||||
snackbar('该服务器无有效域名', 'warning')
|
||||
return
|
||||
}
|
||||
router.push({ path: '/browser', query: { url } })
|
||||
globalBrowser.openUrl(url, { title: item.name })
|
||||
}
|
||||
function openFiles(item: ServerApiItem) {
|
||||
router.push({ path: '/files', query: { server_id: String(item.id) } })
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""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
|
||||
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()
|
||||
|
||||
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
|
||||
@@ -687,6 +687,11 @@ 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)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""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,
|
||||
}
|
||||
|
||||
|
||||
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) or not _is_safe_url(url):
|
||||
continue
|
||||
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))
|
||||
tabs.append({
|
||||
"id": tab_id.strip()[:64],
|
||||
"url": url,
|
||||
"title": str(item.get("title") or _hostname(url))[: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 = 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
|
||||
|
||||
minimized = bool(raw.get("minimized"))
|
||||
|
||||
return {
|
||||
"url_history": url_history,
|
||||
"visits": visits,
|
||||
"tabs": tabs,
|
||||
"active_tab_id": active_tab_id,
|
||||
"minimized": minimized,
|
||||
"rect": 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
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for embedded browser UI state."""
|
||||
|
||||
from server.utils.browser_ui_state import (
|
||||
empty_browser_state,
|
||||
parse_browser_state,
|
||||
record_visit,
|
||||
)
|
||||
|
||||
|
||||
def test_empty_browser_state():
|
||||
assert parse_browser_state(None) == empty_browser_state()
|
||||
|
||||
|
||||
def test_parse_filters_unsafe_urls():
|
||||
raw = {
|
||||
"url_history": ["https://ok.example", "javascript:alert(1)", "ftp://x"],
|
||||
"tabs": [
|
||||
{"id": "t1", "url": "https://a.com", "stack": ["https://a.com"], "stack_index": 0},
|
||||
{"id": "t2", "url": "data:text/html,x"},
|
||||
],
|
||||
}
|
||||
state = parse_browser_state(raw)
|
||||
assert state["url_history"] == ["https://ok.example"]
|
||||
assert len(state["tabs"]) == 1
|
||||
assert state["tabs"][0]["id"] == "t1"
|
||||
|
||||
|
||||
def test_record_visit_dedupes_history():
|
||||
state = empty_browser_state()
|
||||
state = record_visit(state, "https://a.example", "A")
|
||||
state = record_visit(state, "https://b.example", "B")
|
||||
state = record_visit(state, "https://a.example", "A again")
|
||||
assert state["url_history"][0] == "https://a.example"
|
||||
assert state["url_history"][1] == "https://b.example"
|
||||
assert len(state["visits"]) == 3
|
||||
assert state["visits"][0]["title"] == "A again"
|
||||
Reference in New Issue
Block a user