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 }}
|
||||
</v-snackbar>
|
||||
|
||||
<GlobalBrowserPanel v-if="auth.isLoggedIn" :show-launcher="auth.isLoggedIn" />
|
||||
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
@@ -249,8 +247,6 @@ import {
|
||||
} from '@/composables/useScriptSubmitToast'
|
||||
import { clearCachedQueries } from '@/composables/useCachedQuery'
|
||||
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||
import GlobalBrowserPanel from '@/components/GlobalBrowserPanel.vue'
|
||||
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
|
||||
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
|
||||
import { api } from '@/api'
|
||||
|
||||
@@ -347,7 +343,6 @@ const opsItems = [
|
||||
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
|
||||
{ to: '/servers', title: '服务器', icon: 'mdi-server' },
|
||||
{ to: '/terminal', title: '终端', icon: 'mdi-console' },
|
||||
{ to: '/browser', title: '浏览器', icon: 'mdi-web' },
|
||||
{ to: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
|
||||
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' },
|
||||
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' },
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="panelOpen && tabs.length"
|
||||
class="global-browser-float"
|
||||
:class="{ 'global-browser-float--maximized': maximized }"
|
||||
:style="maximized ? undefined : panelStyle"
|
||||
>
|
||||
<v-card border rounded="lg" class="global-browser-panel d-flex flex-column h-100">
|
||||
<div
|
||||
class="global-browser-toolbar d-flex align-center flex-shrink-0"
|
||||
:class="{ 'global-browser-toolbar--draggable': !maximized }"
|
||||
@mousedown="onToolbarMouseDown"
|
||||
>
|
||||
<div
|
||||
v-if="!maximized"
|
||||
class="global-browser-drag-strip flex-shrink-0"
|
||||
title="拖动"
|
||||
@mousedown="onToolbarMouseDown"
|
||||
>
|
||||
<v-icon size="16" class="text-disabled">mdi-drag</v-icon>
|
||||
</div>
|
||||
|
||||
<v-tabs
|
||||
:model-value="activeTabId ?? undefined"
|
||||
density="compact"
|
||||
show-arrows
|
||||
class="global-browser-tabs flex-grow-1 min-w-0"
|
||||
@update:model-value="onTabSelected"
|
||||
@mousedown.stop
|
||||
>
|
||||
<v-tab v-for="tab in tabs" :key="tab.id" :value="tab.id" class="text-none px-2">
|
||||
<span class="text-truncate" style="max-width: 120px">{{ tabLabel(tab) }}</span>
|
||||
<v-btn
|
||||
v-if="tabs.length > 1"
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-1 opacity-60"
|
||||
density="compact"
|
||||
@click.stop="closeTab(tab.id)"
|
||||
/>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
icon="mdi-plus"
|
||||
title="新标签"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="newEmptyTab"
|
||||
/>
|
||||
|
||||
<v-divider vertical class="mx-1" style="height: 20px; align-self: center" @mousedown.stop />
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
icon="mdi-history"
|
||||
title="浏览记录"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="showHistory = !showHistory"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
icon="mdi-restart"
|
||||
title="重启浏览器(保留历史记录)"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="onRestart"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="maximized = !maximized"
|
||||
/>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
icon="mdi-window-minimize"
|
||||
title="最小化(可拖动)"
|
||||
density="compact"
|
||||
class="mr-1"
|
||||
@mousedown.stop
|
||||
@click="minimizePanel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="global-browser-nav d-flex align-center px-2 py-1 flex-shrink-0 border-b">
|
||||
<v-btn icon="mdi-arrow-left" variant="text" size="small" :disabled="!canGoBack" @click="goBack" />
|
||||
<v-btn icon="mdi-arrow-right" variant="text" size="small" :disabled="!canGoForward" @click="goForward" />
|
||||
<v-btn icon="mdi-refresh" variant="text" size="small" :disabled="!activeTab?.url" @click="refreshActive" />
|
||||
<v-text-field
|
||||
v-model="addressDraft"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
placeholder="https://example.com"
|
||||
prepend-inner-icon="mdi-web"
|
||||
class="global-browser-url mx-2"
|
||||
@keydown.enter.prevent="onGo"
|
||||
/>
|
||||
<v-btn color="primary" variant="flat" size="small" class="text-none" @click="onGo">打开</v-btn>
|
||||
<v-btn
|
||||
icon="mdi-open-in-new"
|
||||
variant="text"
|
||||
size="small"
|
||||
:disabled="!activeTab?.url"
|
||||
@click="openExternal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="lastError"
|
||||
type="warning"
|
||||
density="compact"
|
||||
variant="tonal"
|
||||
class="ma-2 mb-0 flex-shrink-0"
|
||||
closable
|
||||
@click:close="lastError = null"
|
||||
>
|
||||
{{ lastError }}
|
||||
</v-alert>
|
||||
|
||||
<div class="global-browser-main d-flex flex-grow-1 min-h-0">
|
||||
<aside v-if="showHistory" class="global-browser-history flex-shrink-0 border-e">
|
||||
<div class="text-caption font-weight-medium px-3 py-2">浏览记录(已同步账号)</div>
|
||||
<v-list density="compact" nav class="py-0 global-browser-history-list">
|
||||
<v-list-item
|
||||
v-for="(visit, idx) in visits"
|
||||
:key="`${visit.at}-${idx}`"
|
||||
:title="visit.title"
|
||||
:subtitle="visit.url"
|
||||
@click="openFromHistory(visit.url)"
|
||||
/>
|
||||
<v-list-item v-if="!visits.length" title="暂无记录" disabled />
|
||||
</v-list>
|
||||
</aside>
|
||||
|
||||
<div class="global-browser-content flex-grow-1 d-flex flex-column min-w-0 min-h-0">
|
||||
<div
|
||||
v-if="!activeTab?.url"
|
||||
class="flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis pa-4"
|
||||
>
|
||||
<v-icon icon="mdi-web" size="48" class="mb-3 opacity-50" />
|
||||
<p class="text-body-2 text-center">输入地址后按回车或点「打开」</p>
|
||||
<p class="text-caption mt-2 text-center">
|
||||
浏览器不可关闭,仅可最小化或重启;页面在您当前的 Chrome / Firefox 引擎中渲染。
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<iframe
|
||||
:key="activeTab.frameKey"
|
||||
:src="activeTab.url"
|
||||
class="global-browser-frame flex-grow-1"
|
||||
title="内置浏览器"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis px-2 py-1 flex-shrink-0 global-browser-hint">
|
||||
若空白,可能禁止 iframe 嵌入 — 请用「新标签打开」。
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!maximized"
|
||||
class="global-browser-resize"
|
||||
title="拖动调整大小"
|
||||
@mousedown.stop="onResizeStart"
|
||||
/>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Minimized — draggable floating bar (any corner) -->
|
||||
<div
|
||||
v-if="minimized && tabs.length"
|
||||
class="global-browser-mini-float"
|
||||
:style="miniPanelStyle"
|
||||
@mousedown="onMiniFloatMouseDown"
|
||||
>
|
||||
<v-card border rounded="lg" class="global-browser-mini-panel h-100 d-flex flex-column">
|
||||
<div
|
||||
class="global-browser-mini-toolbar d-flex align-center px-2 flex-grow-1 global-browser-toolbar--draggable"
|
||||
@dblclick.stop="openPanel"
|
||||
>
|
||||
<div
|
||||
class="global-browser-drag-strip global-browser-drag-strip--mini flex-shrink-0"
|
||||
title="拖动"
|
||||
@mousedown.stop="onMiniDragStart"
|
||||
>
|
||||
<v-icon size="16" class="text-disabled">mdi-drag</v-icon>
|
||||
</div>
|
||||
<v-icon icon="mdi-web" size="18" class="mr-2 flex-shrink-0" />
|
||||
<span
|
||||
class="text-caption text-truncate flex-grow-1 min-w-0 global-browser-mini-title"
|
||||
@click="onMiniTitleClick"
|
||||
>
|
||||
{{ activeTab ? tabLabel(activeTab) : '浏览器' }}
|
||||
</span>
|
||||
<v-chip v-if="tabs.length > 1" size="x-small" class="mx-1 flex-shrink-0">{{ tabs.length }}</v-chip>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-restart"
|
||||
title="重启浏览器"
|
||||
@mousedown.stop
|
||||
@click.stop="onRestart"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-dock-window"
|
||||
title="展开"
|
||||
@mousedown.stop
|
||||
@click.stop="openPanel"
|
||||
/>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
v-if="showLauncher"
|
||||
class="global-browser-launcher"
|
||||
color="primary"
|
||||
icon="mdi-web"
|
||||
size="large"
|
||||
elevation="4"
|
||||
title="展开浏览器"
|
||||
@click="onLauncherClick"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<v-dialog v-model="showRestartConfirm" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>重启浏览器?</v-card-title>
|
||||
<v-card-text>将关闭所有标签并打开空白页,浏览历史记录会保留。</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showRestartConfirm = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="confirmRestart">重启</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useFloatingPanel, type FloatRect } from '@/composables/useFloatingPanel'
|
||||
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
|
||||
|
||||
defineProps<{ showLauncher: boolean }>()
|
||||
|
||||
const showHistory = ref(false)
|
||||
const showRestartConfirm = ref(false)
|
||||
const maximized = ref(false)
|
||||
|
||||
const {
|
||||
panelOpen,
|
||||
minimized,
|
||||
lastError,
|
||||
addressDraft,
|
||||
visits,
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
panelRect,
|
||||
minimizedRect,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
tabLabel,
|
||||
openPanel,
|
||||
minimizePanel,
|
||||
restartBrowser,
|
||||
navigate,
|
||||
refreshActive,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
switchTab,
|
||||
closeTab,
|
||||
newEmptyTab,
|
||||
openFromHistory,
|
||||
savePanelRect,
|
||||
saveMinimizedRect,
|
||||
bootstrap,
|
||||
} = useGlobalBrowser()
|
||||
|
||||
function defaultMiniRect(): FloatRect {
|
||||
const w = 320
|
||||
const h = 52
|
||||
return {
|
||||
x: Math.max(16, window.innerWidth - w - 16),
|
||||
y: Math.max(16, window.innerHeight - h - 16),
|
||||
w,
|
||||
h,
|
||||
}
|
||||
}
|
||||
|
||||
const panelFallback: FloatRect = { x: 80, y: 72, w: 980, h: 640 }
|
||||
const { rect, panelStyle, startDrag, startResize, centerOnScreen } = useFloatingPanel(
|
||||
'nexus_global_browser_rect_v1',
|
||||
panelFallback,
|
||||
)
|
||||
|
||||
const {
|
||||
rect: miniRect,
|
||||
panelStyle: miniPanelStyle,
|
||||
startDrag: startMiniDrag,
|
||||
} = useFloatingPanel('nexus_global_browser_mini_rect_v1', defaultMiniRect(), {
|
||||
minW: 200,
|
||||
minH: 44,
|
||||
maxW: 520,
|
||||
maxH: 56,
|
||||
})
|
||||
|
||||
let miniDragMoved = false
|
||||
let suppressMiniTitleClick = false
|
||||
|
||||
function seedRectFromServer() {
|
||||
if (panelRect.value) {
|
||||
rect.value = { ...panelRect.value }
|
||||
} else {
|
||||
centerOnScreen()
|
||||
}
|
||||
if (minimizedRect.value) {
|
||||
miniRect.value = { ...minimizedRect.value }
|
||||
} else {
|
||||
miniRect.value = defaultMiniRect()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await bootstrap()
|
||||
seedRectFromServer()
|
||||
})
|
||||
|
||||
watch(rect, (value) => {
|
||||
savePanelRect({ ...value })
|
||||
}, { deep: true })
|
||||
|
||||
watch(miniRect, (value) => {
|
||||
saveMinimizedRect({ ...value })
|
||||
}, { deep: true })
|
||||
|
||||
function isDragBlockedTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return true
|
||||
return Boolean(target.closest('button, .v-tab, .v-btn, input, a, .v-field, .v-chip'))
|
||||
}
|
||||
|
||||
function onToolbarMouseDown(e: MouseEvent) {
|
||||
if (maximized.value) return
|
||||
if (isDragBlockedTarget(e.target)) return
|
||||
e.preventDefault()
|
||||
startDrag(e)
|
||||
}
|
||||
|
||||
function onMiniDragStart(e: MouseEvent) {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
miniDragMoved = false
|
||||
const originX = e.clientX
|
||||
const originY = e.clientY
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (Math.abs(ev.clientX - originX) + Math.abs(ev.clientY - originY) > 4) {
|
||||
miniDragMoved = true
|
||||
}
|
||||
}
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
suppressMiniTitleClick = miniDragMoved
|
||||
if (miniDragMoved) {
|
||||
window.setTimeout(() => {
|
||||
suppressMiniTitleClick = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
startMiniDrag(e)
|
||||
}
|
||||
|
||||
function onMiniFloatMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (isDragBlockedTarget(e.target)) return
|
||||
if ((e.target as HTMLElement).closest('.global-browser-drag-strip')) return
|
||||
e.preventDefault()
|
||||
onMiniDragStart(e)
|
||||
}
|
||||
|
||||
function onResizeStart(e: MouseEvent) {
|
||||
startResize(e)
|
||||
}
|
||||
|
||||
function onTabSelected(id: unknown) {
|
||||
if (typeof id === 'string') switchTab(id)
|
||||
}
|
||||
|
||||
function onGo() {
|
||||
navigate(addressDraft.value)
|
||||
}
|
||||
|
||||
function onLauncherClick() {
|
||||
if (tabs.value.length) {
|
||||
openPanel()
|
||||
return
|
||||
}
|
||||
newEmptyTab()
|
||||
}
|
||||
|
||||
function onMiniTitleClick() {
|
||||
if (suppressMiniTitleClick) return
|
||||
openPanel()
|
||||
}
|
||||
|
||||
function onRestart() {
|
||||
showRestartConfirm.value = true
|
||||
}
|
||||
|
||||
function confirmRestart() {
|
||||
showRestartConfirm.value = false
|
||||
restartBrowser()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.global-browser-float,
|
||||
.global-browser-mini-float {
|
||||
position: fixed;
|
||||
z-index: 2350;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
touch-action: none;
|
||||
}
|
||||
.global-browser-float--maximized {
|
||||
inset: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
.global-browser-panel,
|
||||
.global-browser-mini-panel {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.global-browser-toolbar,
|
||||
.global-browser-mini-toolbar {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
.global-browser-mini-toolbar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
.global-browser-toolbar--draggable {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.global-browser-drag-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 40px;
|
||||
margin-left: 4px;
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
}
|
||||
.global-browser-drag-strip--mini {
|
||||
width: 24px;
|
||||
height: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.global-browser-mini-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
.global-browser-nav {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.global-browser-url {
|
||||
flex: 1 1 auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
.global-browser-main {
|
||||
min-height: 0;
|
||||
}
|
||||
.global-browser-history {
|
||||
width: 280px;
|
||||
max-width: 40%;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.global-browser-history-list {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.global-browser-frame {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.global-browser-hint {
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
.global-browser-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: nwse-resize;
|
||||
z-index: 3;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
transparent 50%,
|
||||
rgba(var(--v-theme-on-surface), 0.25) 50%
|
||||
);
|
||||
}
|
||||
.global-browser-launcher {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 2340;
|
||||
}
|
||||
</style>
|
||||
@@ -63,6 +63,12 @@
|
||||
density="comfortable"
|
||||
@update:page="$emit('update:page', $event); $emit('page-change')"
|
||||
>
|
||||
<template #item.server_name="{ item }">
|
||||
<span
|
||||
class="text-body-2 push-history-server-name"
|
||||
:title="item.server_name ?? ''"
|
||||
>{{ item.server_name }}</span>
|
||||
</template>
|
||||
<template #item.status="{ item }">
|
||||
<v-chip :color="logStatusColor(item.status)" size="x-small" variant="tonal" label border="sm">
|
||||
{{ logStatusLabel(item.status) }}
|
||||
@@ -145,3 +151,14 @@ defineEmits<{
|
||||
diagnose: [item: PushItem]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.push-history-server-name {
|
||||
display: inline-block;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in results" :key="row.server_id">
|
||||
<td>{{ row.server_name }}</td>
|
||||
<td>
|
||||
<span class="push-preview-server-name" :title="row.server_name">{{ row.server_name }}</span>
|
||||
</td>
|
||||
<td>{{ row.stats?.files_total ?? '—' }}</td>
|
||||
<td>{{ row.stats?.files_transferred ?? '—' }}</td>
|
||||
<td>{{ row.stats ? formatSize(row.stats.transfer_size_bytes ?? 0) : '—' }}</td>
|
||||
@@ -89,3 +91,14 @@ defineEmits<{
|
||||
'confirm-push': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.push-preview-server-name {
|
||||
display: inline-block;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,7 +47,10 @@
|
||||
</span>
|
||||
</div>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="s in items" :key="s.id" :title="s.name">
|
||||
<v-list-item v-for="s in items" :key="s.id">
|
||||
<template #title>
|
||||
<span class="push-progress-name" :title="s.name">{{ s.name }}</span>
|
||||
</template>
|
||||
<template #append>
|
||||
<v-icon v-if="s.status === 'pending'" color="grey">mdi-clock-outline</v-icon>
|
||||
<v-icon v-else-if="s.status === 'running'" color="blue" class="mdi-spin">mdi-loading</v-icon>
|
||||
@@ -85,3 +88,12 @@ defineEmits<{
|
||||
'retry-all': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.push-progress-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<v-card elevation="0" border rounded="lg" class="mb-4">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-card-title class="d-flex align-center flex-wrap ga-2">
|
||||
<v-checkbox
|
||||
:model-value="selectAll"
|
||||
label="全选"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mr-4"
|
||||
class="mr-2"
|
||||
@update:model-value="$emit('toggle-all')"
|
||||
/>
|
||||
<v-text-field
|
||||
@@ -28,6 +28,18 @@
|
||||
title="刷新在线状态"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn-toggle
|
||||
v-model="viewMode"
|
||||
mandatory
|
||||
density="compact"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
divided
|
||||
>
|
||||
<v-btn value="grid" size="small" icon="mdi-view-grid" title="卡片视图" />
|
||||
<v-btn value="list" size="small" icon="mdi-view-list" title="列表视图" />
|
||||
</v-btn-toggle>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-2">
|
||||
@@ -52,22 +64,31 @@
|
||||
({{ group.servers.filter(s => selectedIds.has(s.id)).length }}/{{ group.servers.length }})
|
||||
</span>
|
||||
</div>
|
||||
<v-row dense>
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<v-row v-if="viewMode === 'grid'" dense>
|
||||
<v-col v-for="s in group.servers" :key="s.id" cols="6" sm="4" md="3" lg="2">
|
||||
<v-card
|
||||
:color="selectedIds.has(s.id) ? 'primary' : undefined"
|
||||
:variant="selectedIds.has(s.id) ? 'tonal' : 'outlined'"
|
||||
rounded="lg"
|
||||
class="pa-3 cursor-pointer"
|
||||
class="pa-3 cursor-pointer push-server-card"
|
||||
@click="$emit('toggle-server', s.id)"
|
||||
>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-icon :color="serverOnlineColor(s.is_online)" size="12">mdi-circle</v-icon>
|
||||
<span class="text-body-2 font-weight-medium text-truncate">{{ s.name }}</span>
|
||||
<div class="d-flex align-center ga-2 min-w-0">
|
||||
<v-icon :color="serverOnlineColor(s.is_online)" size="12" class="flex-shrink-0">mdi-circle</v-icon>
|
||||
<span
|
||||
class="text-body-2 font-weight-medium push-server-card__name push-server-copy-name"
|
||||
:title="`${serverLabelTitle(s)}\n点击复制名称`"
|
||||
@click.stop="copyServerName(s.name)"
|
||||
>{{ s.name }}</span>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis text-truncate">{{ s.domain }}</div>
|
||||
<div
|
||||
class="text-caption text-medium-emphasis text-truncate"
|
||||
class="text-caption text-medium-emphasis push-server-card__line"
|
||||
:title="s.domain || ''"
|
||||
>{{ s.domain }}</div>
|
||||
<div
|
||||
class="text-caption text-medium-emphasis push-server-card__line"
|
||||
:title="s.target_path || '未配置目标路径'"
|
||||
>
|
||||
→ {{ s.target_path?.trim() || '未配置' }}
|
||||
@@ -86,6 +107,67 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<v-table v-else density="compact" class="push-server-list rounded-lg border">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="push-server-list__col-check" />
|
||||
<th class="push-server-list__col-online" />
|
||||
<th>名称</th>
|
||||
<th>域名</th>
|
||||
<th>目标路径</th>
|
||||
<th class="push-server-list__col-status">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="s in group.servers"
|
||||
:key="s.id"
|
||||
class="push-server-list__row cursor-pointer"
|
||||
:class="{ 'push-server-list__row--selected': selectedIds.has(s.id) }"
|
||||
@click="$emit('toggle-server', s.id)"
|
||||
>
|
||||
<td @click.stop>
|
||||
<v-checkbox
|
||||
:model-value="selectedIds.has(s.id)"
|
||||
density="compact"
|
||||
hide-details
|
||||
@update:model-value="$emit('toggle-server', s.id)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<v-icon :color="serverOnlineColor(s.is_online)" size="12">mdi-circle</v-icon>
|
||||
</td>
|
||||
<td class="push-server-list__name-cell">
|
||||
<span
|
||||
class="push-server-copy-name"
|
||||
:title="`${s.name}\n点击复制名称`"
|
||||
@click.stop="copyServerName(s.name)"
|
||||
>{{ s.name }}</span>
|
||||
</td>
|
||||
<td class="text-caption text-medium-emphasis push-server-list__ellipsis" :title="s.domain || ''">
|
||||
{{ s.domain || '—' }}
|
||||
</td>
|
||||
<td class="text-caption text-medium-emphasis push-server-list__ellipsis" :title="s.target_path || '未配置'">
|
||||
{{ s.target_path?.trim() || '未配置' }}
|
||||
</td>
|
||||
<td>
|
||||
<v-chip
|
||||
v-if="pushStatus[s.id]"
|
||||
:color="pushStatusChipColor(pushStatus[s.id])"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
label
|
||||
border="sm"
|
||||
>
|
||||
{{ pushStatus[s.id] }}
|
||||
</v-chip>
|
||||
<span v-else class="text-caption text-medium-emphasis">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
@@ -94,6 +176,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { pushStatusChipColor, serverOnlineColor } from '@/composables/push/labels'
|
||||
import { usePushServerViewMode } from '@/composables/push/usePushServerViewMode'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import type { PushServerItem } from '@/composables/push/types'
|
||||
|
||||
defineProps<{
|
||||
@@ -113,4 +197,80 @@ defineEmits<{
|
||||
'toggle-category': [category: string]
|
||||
'toggle-server': [id: number]
|
||||
}>()
|
||||
|
||||
const { viewMode } = usePushServerViewMode()
|
||||
const snackbar = useSnackbar()
|
||||
|
||||
function serverLabelTitle(s: PushServerItem): string {
|
||||
const parts = [s.name]
|
||||
if (s.domain?.trim()) parts.push(s.domain.trim())
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
async function copyServerName(name: string) {
|
||||
const text = name.trim()
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
snackbar('名称已复制')
|
||||
} catch {
|
||||
snackbar('复制失败', 'error')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.push-server-card__name {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.push-server-card__line {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.push-server-copy-name {
|
||||
cursor: copy;
|
||||
}
|
||||
.push-server-copy-name:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
.push-server-list__col-check {
|
||||
width: 44px;
|
||||
}
|
||||
.push-server-list__col-online {
|
||||
width: 28px;
|
||||
}
|
||||
.push-server-list__col-status {
|
||||
width: 96px;
|
||||
}
|
||||
.push-server-list__row--selected {
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
.push-server-list__name-cell {
|
||||
max-width: 0;
|
||||
width: 32%;
|
||||
word-break: break-word;
|
||||
}
|
||||
.push-server-list__ellipsis {
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.push-server-list :deep(th:nth-child(4)),
|
||||
.push-server-list :deep(td:nth-child(4)) {
|
||||
width: 24%;
|
||||
}
|
||||
.push-server-list :deep(th:nth-child(5)),
|
||||
.push-server-list :deep(td:nth-child(5)) {
|
||||
width: 28%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,7 +24,7 @@ const LOG_SYNC_MODE_OPTIONS = [
|
||||
|
||||
const LOG_HEADERS = [
|
||||
{ title: '时间', key: 'started_at', width: 170 },
|
||||
{ title: '服务器', key: 'server_name', width: 120 },
|
||||
{ title: '服务器', key: 'server_name', width: 200 },
|
||||
{ title: '状态', key: 'status', width: 88 },
|
||||
{ title: '模式', key: 'sync_mode', width: 88 },
|
||||
{ title: '源路径', key: 'source_path' },
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export type PushServerViewMode = 'grid' | 'list'
|
||||
|
||||
const LS_KEY = 'nexus_push_server_view_v1'
|
||||
|
||||
function readStoredViewMode(): PushServerViewMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY)
|
||||
return raw === 'list' ? 'list' : 'grid'
|
||||
} catch {
|
||||
return 'grid'
|
||||
}
|
||||
}
|
||||
|
||||
const viewMode = ref<PushServerViewMode>(readStoredViewMode())
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, mode)
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
})
|
||||
|
||||
export function usePushServerViewMode() {
|
||||
return { viewMode }
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
/**
|
||||
* Global floating browser — persists tabs/history to MySQL via /api/browser/state.
|
||||
* Singleton module state survives route changes.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { normalizeBrowserUrl } from '@/utils/browserUrl'
|
||||
import type { FloatRect } from '@/composables/useFloatingPanel'
|
||||
|
||||
const LEGACY_HISTORY_KEY = 'nexus_browser_history_v1'
|
||||
const SAVE_DEBOUNCE_MS = 700
|
||||
|
||||
export interface BrowserVisit {
|
||||
url: string
|
||||
title: string
|
||||
at: string
|
||||
}
|
||||
|
||||
export interface BrowserTab {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
stack: string[]
|
||||
stackIndex: number
|
||||
frameKey: number
|
||||
}
|
||||
|
||||
export interface BrowserStateResponse {
|
||||
url_history: string[]
|
||||
visits: BrowserVisit[]
|
||||
tabs: Array<{
|
||||
id: string
|
||||
url: string
|
||||
title?: string
|
||||
stack?: string[]
|
||||
stack_index?: number
|
||||
}>
|
||||
active_tab_id: string | null
|
||||
minimized: boolean
|
||||
rect: FloatRect | null
|
||||
minimized_rect: FloatRect | null
|
||||
}
|
||||
|
||||
const bootstrapped = ref(false)
|
||||
const saving = ref(false)
|
||||
const panelOpen = ref(false)
|
||||
const minimized = ref(false)
|
||||
const maximized = ref(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
const addressDraft = ref('')
|
||||
const urlHistory = ref<string[]>([])
|
||||
const visits = ref<BrowserVisit[]>([])
|
||||
const tabs = ref<BrowserTab[]>([])
|
||||
const activeTabId = ref<string | null>(null)
|
||||
const panelRect = ref<FloatRect | null>(null)
|
||||
const minimizedRect = ref<FloatRect | null>(null)
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function tabLabel(tab: BrowserTab): string {
|
||||
try {
|
||||
return tab.title || new URL(tab.url).hostname
|
||||
} catch {
|
||||
return tab.title || tab.url
|
||||
}
|
||||
}
|
||||
|
||||
function newTabId(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
function serializeTabs(): BrowserStateResponse['tabs'] {
|
||||
return tabs.value.map((t) => ({
|
||||
id: t.id,
|
||||
url: t.url,
|
||||
title: t.title,
|
||||
stack: t.stack,
|
||||
stack_index: t.stackIndex,
|
||||
}))
|
||||
}
|
||||
|
||||
function applyServerState(data: BrowserStateResponse) {
|
||||
urlHistory.value = data.url_history || []
|
||||
visits.value = (data.visits || []).map((v) => ({
|
||||
url: v.url,
|
||||
title: v.title || v.url,
|
||||
at: v.at,
|
||||
}))
|
||||
tabs.value = (data.tabs || []).map((t) => ({
|
||||
id: t.id,
|
||||
url: t.url,
|
||||
title: t.title || t.url,
|
||||
stack: t.stack?.length ? t.stack : [t.url],
|
||||
stackIndex: typeof t.stack_index === 'number' ? t.stack_index : (t.stack?.length ?? 1) - 1,
|
||||
frameKey: 0,
|
||||
}))
|
||||
activeTabId.value = data.active_tab_id
|
||||
if (!activeTabId.value && tabs.value.length) {
|
||||
activeTabId.value = tabs.value[0]!.id
|
||||
}
|
||||
minimized.value = Boolean(data.minimized)
|
||||
panelRect.value = data.rect
|
||||
minimizedRect.value = data.minimized_rect
|
||||
panelOpen.value = tabs.value.length > 0 && !minimized.value
|
||||
syncAddressFromActive()
|
||||
}
|
||||
|
||||
function syncAddressFromActive() {
|
||||
const tab = activeTab.value
|
||||
addressDraft.value = tab?.url ?? ''
|
||||
}
|
||||
|
||||
const activeTab = computed(() => tabs.value.find((t) => t.id === activeTabId.value) ?? null)
|
||||
|
||||
const isActive = computed(() => tabs.value.length > 0 || panelOpen.value)
|
||||
|
||||
const canGoBack = computed(() => {
|
||||
const tab = activeTab.value
|
||||
return tab ? tab.stackIndex > 0 : false
|
||||
})
|
||||
|
||||
const canGoForward = computed(() => {
|
||||
const tab = activeTab.value
|
||||
return tab ? tab.stackIndex < tab.stack.length - 1 : false
|
||||
})
|
||||
|
||||
function scheduleSave(extra?: { recordUrl?: string; recordTitle?: string }) {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
void persistState(extra)
|
||||
}, SAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
async function persistState(extra?: { recordUrl?: string; recordTitle?: string }) {
|
||||
saving.value = true
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
tabs: serializeTabs(),
|
||||
active_tab_id: activeTabId.value,
|
||||
minimized: minimized.value,
|
||||
rect: panelRect.value,
|
||||
minimized_rect: minimizedRect.value,
|
||||
}
|
||||
if (extra?.recordUrl) {
|
||||
body.record_url = extra.recordUrl
|
||||
if (extra.recordTitle) body.record_title = extra.recordTitle
|
||||
} else {
|
||||
body.url_history = urlHistory.value
|
||||
body.visits = visits.value
|
||||
}
|
||||
const res = await http.put<BrowserStateResponse>('/browser/state', body)
|
||||
applyServerState(res)
|
||||
} catch {
|
||||
/* keep local state */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateLegacyHistory() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(LEGACY_HISTORY_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return
|
||||
const urls = parsed.filter((u): u is string => typeof u === 'string' && !!normalizeBrowserUrl(u))
|
||||
if (!urls.length) return
|
||||
for (const url of [...urls].reverse()) {
|
||||
const normalized = normalizeBrowserUrl(url)
|
||||
if (normalized) {
|
||||
await persistState({ recordUrl: normalized, recordTitle: normalized })
|
||||
}
|
||||
}
|
||||
sessionStorage.removeItem(LEGACY_HISTORY_KEY)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapped.value) return
|
||||
try {
|
||||
const res = await http.get<BrowserStateResponse>('/browser/state')
|
||||
applyServerState(res)
|
||||
if (!visits.value.length && !urlHistory.value.length) {
|
||||
await migrateLegacyHistory()
|
||||
}
|
||||
} catch {
|
||||
/* offline */
|
||||
} finally {
|
||||
bootstrapped.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
panelOpen.value = true
|
||||
minimized.value = false
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function minimizePanel() {
|
||||
minimized.value = true
|
||||
panelOpen.value = false
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
/** Reset to a single blank tab; browsing history is kept. */
|
||||
function restartBrowser() {
|
||||
const tab: BrowserTab = {
|
||||
id: newTabId(),
|
||||
url: '',
|
||||
title: '新标签',
|
||||
stack: [],
|
||||
stackIndex: -1,
|
||||
frameKey: 0,
|
||||
}
|
||||
tabs.value = [tab]
|
||||
activeTabId.value = tab.id
|
||||
addressDraft.value = ''
|
||||
lastError.value = null
|
||||
openPanel()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function openUrl(input: string, opts?: { title?: string; newTab?: boolean }) {
|
||||
const normalized = normalizeBrowserUrl(input)
|
||||
if (!normalized) {
|
||||
lastError.value = '请输入有效的 http 或 https 地址'
|
||||
return false
|
||||
}
|
||||
lastError.value = null
|
||||
|
||||
if (opts?.newTab || !tabs.value.length || !activeTab.value) {
|
||||
const tab: BrowserTab = {
|
||||
id: newTabId(),
|
||||
url: normalized,
|
||||
title: opts?.title || normalized,
|
||||
stack: [normalized],
|
||||
stackIndex: 0,
|
||||
frameKey: 0,
|
||||
}
|
||||
tabs.value = [...tabs.value, tab].slice(-12)
|
||||
activeTabId.value = tab.id
|
||||
} else {
|
||||
const tab = activeTab.value
|
||||
const stack = tab.stack.slice(0, tab.stackIndex + 1)
|
||||
if (!stack.length || stack[stack.length - 1] !== normalized) stack.push(normalized)
|
||||
tab.url = normalized
|
||||
if (opts?.title) tab.title = opts.title
|
||||
tab.stack = stack
|
||||
tab.stackIndex = stack.length - 1
|
||||
tab.frameKey += 1
|
||||
}
|
||||
|
||||
addressDraft.value = normalized
|
||||
openPanel()
|
||||
scheduleSave({ recordUrl: normalized, recordTitle: opts?.title })
|
||||
return true
|
||||
}
|
||||
|
||||
function navigate(input: string) {
|
||||
return openUrl(input)
|
||||
}
|
||||
|
||||
function refreshActive() {
|
||||
const tab = activeTab.value
|
||||
if (!tab) return
|
||||
tab.frameKey += 1
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const tab = activeTab.value
|
||||
if (!tab || tab.stackIndex <= 0) return
|
||||
tab.stackIndex -= 1
|
||||
tab.url = tab.stack[tab.stackIndex]!
|
||||
tab.frameKey += 1
|
||||
addressDraft.value = tab.url
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
const tab = activeTab.value
|
||||
if (!tab || tab.stackIndex >= tab.stack.length - 1) return
|
||||
tab.stackIndex += 1
|
||||
tab.url = tab.stack[tab.stackIndex]!
|
||||
tab.frameKey += 1
|
||||
addressDraft.value = tab.url
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
const tab = activeTab.value
|
||||
if (!tab) return
|
||||
window.open(tab.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function switchTab(id: string) {
|
||||
activeTabId.value = id
|
||||
syncAddressFromActive()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function closeTab(id: string) {
|
||||
if (tabs.value.length <= 1) return
|
||||
const idx = tabs.value.findIndex((t) => t.id === id)
|
||||
if (idx < 0) return
|
||||
const next = tabs.value.filter((t) => t.id !== id)
|
||||
tabs.value = next
|
||||
if (activeTabId.value === id) {
|
||||
activeTabId.value = next[idx]?.id ?? next[idx - 1]?.id ?? null
|
||||
}
|
||||
syncAddressFromActive()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function newEmptyTab() {
|
||||
const tab: BrowserTab = {
|
||||
id: newTabId(),
|
||||
url: '',
|
||||
title: '新标签',
|
||||
stack: [],
|
||||
stackIndex: -1,
|
||||
frameKey: 0,
|
||||
}
|
||||
tabs.value = [...tabs.value, tab].slice(-12)
|
||||
activeTabId.value = tab.id
|
||||
addressDraft.value = ''
|
||||
openPanel()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function openFromHistory(url: string) {
|
||||
openUrl(url)
|
||||
}
|
||||
|
||||
function savePanelRect(rect: FloatRect) {
|
||||
panelRect.value = rect
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function saveMinimizedRect(rect: FloatRect) {
|
||||
minimizedRect.value = rect
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
export function useGlobalBrowser() {
|
||||
return {
|
||||
bootstrapped,
|
||||
saving,
|
||||
panelOpen,
|
||||
minimized,
|
||||
maximized,
|
||||
lastError,
|
||||
addressDraft,
|
||||
urlHistory,
|
||||
visits,
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
panelRect,
|
||||
minimizedRect,
|
||||
isActive,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
tabLabel,
|
||||
bootstrap,
|
||||
openPanel,
|
||||
minimizePanel,
|
||||
restartBrowser,
|
||||
openUrl,
|
||||
navigate,
|
||||
refreshActive,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
switchTab,
|
||||
closeTab,
|
||||
newEmptyTab,
|
||||
openFromHistory,
|
||||
savePanelRect,
|
||||
saveMinimizedRect,
|
||||
scheduleSave,
|
||||
}
|
||||
}
|
||||
@@ -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 { formatApiError } from '@/utils/apiError'
|
||||
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
|
||||
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
|
||||
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
|
||||
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
||||
import StatCardsRow from '@/components/StatCardsRow.vue'
|
||||
@@ -763,7 +762,6 @@ const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
|
||||
|
||||
const snackbar = useSnackbar()
|
||||
const router = useRouter()
|
||||
const globalBrowser = useGlobalBrowser()
|
||||
const route = useRoute()
|
||||
const showCredentialsDialog = ref(false)
|
||||
|
||||
@@ -1434,7 +1432,7 @@ function openBrowser(item: ServerApiItem) {
|
||||
snackbar('该服务器无有效域名', 'warning')
|
||||
return
|
||||
}
|
||||
globalBrowser.openUrl(url, { title: item.name })
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
function openFiles(item: ServerApiItem) {
|
||||
router.push({ path: '/files', query: { server_id: String(item.id) } })
|
||||
|
||||
@@ -5,7 +5,6 @@ const routes = [
|
||||
{ path: '/', name: 'Dashboard', component: () => import('@/pages/DashboardPage.vue') },
|
||||
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
|
||||
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
|
||||
{ path: '/browser', name: 'Browser', component: () => import('@/pages/BrowserPage.vue') },
|
||||
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
|
||||
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
|
||||
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
@@ -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