feat: 内置 Web 浏览器页与服务器站点入口

侧栏浏览器页 iframe 预览 http(s) 站点;服务器列表「站点」由 domain 跳转,禁止嵌入时可新标签打开。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-11 18:30:44 +08:00
parent 996403ad86
commit 2a6f526bc9
9 changed files with 452 additions and 1 deletions
+36
View File
@@ -0,0 +1,36 @@
# 审计 — 内置 Web 浏览器
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/pages/BrowserPage.vue` | iframe 浏览器页 |
| `frontend/src/composables/useEmbeddedBrowser.ts` | 导航历史 |
| `frontend/src/utils/browserUrl.ts` | URL 白名单校验 |
| `frontend/src/router/index.ts` | `/browser` 路由 |
| `frontend/src/App.vue` | 侧栏 + 全屏布局 |
| `frontend/src/pages/ServersPage.vue` | 站点入口 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 无 SSRF(无后端 fetch | PASS |
| H2 | 仅 http/https | PASS — normalizeBrowserUrl |
| H3 | iframe sandbox | PASS |
| H4 | 鉴权 | PASS — 走路由 guard |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 纯前端 iframe |
## DoD
- [x] type-check
- [x] changelog
## 验证
`#/browser` 与服务器「站点」按钮;禁止嵌入站点用新标签打开。
@@ -0,0 +1,38 @@
# 2026-06-11 内置 Web 浏览器
## 摘要
新增侧栏「浏览器」页:地址栏 + iframe 预览 http(s) 站点;服务器列表增加「站点」快捷入口(由 domain 推导 `https://{host}`)。
## 动机
运维需在 Nexus 内快速查看子机网站,无需切换外部浏览器;与终端、文件管理形成并列运维入口。
## 涉及文件
| 文件 | 变更 |
|------|------|
| `docs/design/specs/2026-06-11-embedded-browser-design.md` | 设计说明 |
| `frontend/src/pages/BrowserPage.vue` | 浏览器页 |
| `frontend/src/composables/useEmbeddedBrowser.ts` | 导航与 session 历史 |
| `frontend/src/utils/browserUrl.ts` | URL 校验与域名推导 |
| `frontend/src/router/index.ts` | 路由 `/browser` |
| `frontend/src/App.vue` | 侧栏入口与全屏布局 |
| `frontend/src/pages/ServersPage.vue` | 「站点」按钮 |
## 限制
- 部分站点 `X-Frame-Options` 禁止嵌入 → 使用「新标签打开」
- 首版无 HTTP 反向代理,不解决所有站点嵌入问题
## 迁移 / 重启
- 仅前端构建部署,无需后端重启或数据库迁移
## 验证
```bash
cd frontend && npm run type-check && npx vite build
```
浏览器:`#/browser` 输入 URL;服务器列表点「站点」应打开对应域名。
@@ -0,0 +1,34 @@
# 内置 Web 浏览器 — 设计说明
## 背景与目标
运维需在 Nexus 内快速预览子机站点(域名),不必切换外部浏览器;与终端、文件管理并列的运维入口。
## 方案对比
| 方案 | 优点 | 缺点 |
|------|------|------|
| **iframe 嵌入** | 无后端、实现快、无 SSRF | 部分站点 `X-Frame-Options` 禁止嵌入 |
| HTTP 反向代理 | 可绕过 frame 限制 | SSRF 风险、需维护代理与 Cookie |
| 仅外链新标签 | 零风险 | 非「内置」 |
**选定**:iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。
## 接口与路由
- 前端 `#/browser?url=https://example.com`
- 服务器列表「站点」→ 带 `url`(由 `domain` 推导 `https://{host}`,不含 SSH 端口)
## 安全
- 仅允许 `http:` / `https:`;拒绝 `javascript:``data:`、凭据 URL
- iframe `sandbox``allow-scripts allow-same-origin allow-forms allow-popups allow-downloads`
- 无后端 fetch,不引入 SSRF
- 不记录浏览审计(被动预览,非 CUD)
## 验收
- [ ] 侧栏「浏览器」进入全屏 iframe 页
- [ ] 地址栏输入 URL 可导航;刷新 / 新标签打开
- [ ] 服务器列表「站点」跳转并加载域名
- [ ] 非法 URL 拒绝并提示
+28 -1
View File
@@ -327,7 +327,8 @@ provide('nexusDrawer', drawer)
const mainLayoutClass = computed(() => ({
'nexus-terminal-main': route.path === '/terminal',
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
'nexus-browser-main': route.path === '/browser',
'nexus-page-main': route.path !== '/terminal' && route.path !== '/browser' && route.path !== '/login',
}))
const scriptExecBarOffset = computed(() => {
@@ -343,6 +344,7 @@ 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' },
@@ -415,6 +417,31 @@ 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,115 @@
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,
}
}
+160
View File
@@ -0,0 +1,160 @@
<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>
</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'
const route = useRoute()
const router = useRouter()
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 = '链接参数无效,请重新输入地址'
}
})
</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>
+18
View File
@@ -245,6 +245,15 @@
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn variant="text" size="x-small" color="primary" density="compact" @click.stop="openTerminal(item)">终端</v-btn>
<v-btn
v-if="item.domain"
variant="text"
size="x-small"
density="compact"
@click.stop="openBrowser(item)"
>
站点
</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="openFiles(item)">文件</v-btn>
<v-btn variant="text" size="x-small" density="compact" :loading="agentDiagnoseLoadingId === item.id" @click.stop="openAgentDiagnose(item)">诊断</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="editServer(item)">编辑</v-btn>
@@ -662,6 +671,7 @@ import { isUnsetTargetPath } from '@/utils/serverTargetPath'
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue'
@@ -1397,6 +1407,14 @@ async function refreshStatCards() {
function openTerminal(item: ServerApiItem) {
router.push({ path: '/terminal', query: { server_id: String(item.id) } })
}
function openBrowser(item: ServerApiItem) {
const url = guessSiteUrlFromDomain(item.domain || '')
if (!url) {
snackbar('该服务器无有效域名', 'warning')
return
}
router.push({ path: '/browser', query: { url } })
}
function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
}
+1
View File
@@ -5,6 +5,7 @@ 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') },
+22
View File
@@ -0,0 +1,22 @@
/** Normalize user input to a safe http(s) URL for embedded iframe navigation. */
export function normalizeBrowserUrl(input: string): string | null {
const trimmed = input.trim()
if (!trimmed) return null
try {
const withProto = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
const parsed = new URL(withProto)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
if (parsed.username || parsed.password) return null
return parsed.href
} catch {
return null
}
}
/** Derive a site URL from server domain (SSH host; strip trailing :port). */
export function guessSiteUrlFromDomain(domain: string): string {
const raw = domain.trim()
if (!raw) return ''
const host = raw.includes(':') ? raw.split(':')[0]!.trim() : raw
return normalizeBrowserUrl(host) ?? `https://${host}`
}