2e8daad802
移除关闭入口;最小化为可拖动浮动条并持久化 minimized_rect;重启保留浏览历史。 Co-authored-by: Cursor <cursoragent@cursor.com>
385 lines
9.0 KiB
TypeScript
385 lines
9.0 KiB
TypeScript
/**
|
|
* 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,
|
|
}
|
|
}
|