feat: 全局浮动浏览器、记录持久化与角落快捷入口

浮动面板可最小化至右下角、左下角随时展开;标签与浏览记录写入 MySQL,切换菜单保持活动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-11 20:10:57 +08:00
parent 2a6f526bc9
commit 8356425814
12 changed files with 1129 additions and 290 deletions
@@ -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
View File
@@ -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,
}
}
+16 -147
View File
@@ -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>
+3 -1
View File
@@ -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) } })
+84
View File
@@ -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
+5
View File
@@ -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)
+153
View File
@@ -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
+36
View File
@@ -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"