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