441 lines
18 KiB
TypeScript
441 lines
18 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type JSX } from 'react'
|
|
import {
|
|
LuArrowLeft,
|
|
LuArrowRight,
|
|
LuChevronDown,
|
|
LuCircleAlert,
|
|
LuGlobe,
|
|
LuNetwork,
|
|
LuPlus,
|
|
LuRotateCw,
|
|
LuX
|
|
} from 'react-icons/lu'
|
|
import type { BrowserUiState } from '@shared/types'
|
|
import { BrowserSlot } from './BrowserSlot'
|
|
import { BrowserProxyForm } from './BrowserProxyForm'
|
|
|
|
/**
|
|
* 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/代理浮层)。
|
|
* 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView
|
|
* (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。
|
|
* 任何全屏浮层(设置/搜索/代理)出现时必须卸载 Slot 隐藏原生视图。
|
|
*/
|
|
|
|
interface BrowserPanelProps {
|
|
state: BrowserUiState | null
|
|
/** 全屏浮层(设置/搜索)打开时暂停原生视图,避免其盖住浮层 */
|
|
suspended: boolean
|
|
onNavigate: (input: string) => void
|
|
onGoBack: () => void
|
|
onGoForward: () => void
|
|
onReload: () => void
|
|
/** 新建页签;传 url 时直接打开该地址(「最近关闭的标签页」恢复) */
|
|
onNewTab: (url?: string) => void
|
|
onSelectTab: (tabId: string) => void
|
|
onCloseTab: (tabId: string) => void
|
|
/** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */
|
|
onCloseAllTabs: () => void
|
|
onAcknowledgeRisk: () => void
|
|
}
|
|
|
|
export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
|
const { state, suspended } = props
|
|
const [address, setAddress] = useState('')
|
|
const [proxyOpen, setProxyOpen] = useState(false)
|
|
const [tabListOpen, setTabListOpen] = useState(false)
|
|
const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null)
|
|
|
|
const activeTab = useMemo(
|
|
() => state?.tabs.find((t) => t.tabId === state.activeTabId) ?? null,
|
|
[state]
|
|
)
|
|
|
|
// 地址栏跟随激活页 URL(用户正在输入时不打扰)
|
|
const addressRef = useRef<HTMLInputElement>(null)
|
|
useEffect(() => {
|
|
if (document.activeElement !== addressRef.current) {
|
|
setAddress(activeTab?.url ?? '')
|
|
}
|
|
}, [activeTab?.url])
|
|
|
|
// 菜单打开时 Esc 关闭
|
|
useEffect(() => {
|
|
if (!tabListOpen && !tabMenu) return
|
|
const onKey = (e: KeyboardEvent): void => {
|
|
if (e.key === 'Escape') {
|
|
setTabListOpen(false)
|
|
setTabMenu(null)
|
|
}
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [tabListOpen, tabMenu])
|
|
|
|
const submitAddress = (): void => {
|
|
const value = address.trim()
|
|
if (value) props.onNavigate(value)
|
|
addressRef.current?.blur()
|
|
}
|
|
|
|
const riskBanner = state && !state.riskAcknowledged
|
|
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上)
|
|
const overlayOpen = proxyOpen || tabListOpen || tabMenu !== null
|
|
// 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单)
|
|
const hideCause = suspended || overlayOpen
|
|
|
|
// 冻结帧:让位前先截当前页面画面,隐藏期间以快照占位——视觉上等于菜单浮在
|
|
// 页面之上。直接白屏占位会让整页「消失」,看起来像浏览器故障(此前报告的
|
|
// 「展开标签列表时页面区域空白」即由此而来)。顺序:先截帧(视图仍可见),
|
|
// 下一拍再隐藏;恢复时先挂回视图、快照被盖住后再清,全程不闪白。
|
|
const [frozen, setFrozen] = useState<string | null>(null)
|
|
const [slotHidden, setSlotHidden] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const tabId = activeTab?.tabId
|
|
if (!tabId) {
|
|
setSlotHidden(false)
|
|
setFrozen(null)
|
|
return
|
|
}
|
|
let alive = true
|
|
if (hideCause && !slotHidden) {
|
|
void window.api
|
|
.browserCaptureTab(tabId)
|
|
.then((dataUrl) => {
|
|
if (alive && dataUrl) setFrozen(dataUrl)
|
|
})
|
|
.catch(() => undefined)
|
|
.finally(() => {
|
|
if (alive) setSlotHidden(true)
|
|
})
|
|
return () => {
|
|
alive = false
|
|
}
|
|
}
|
|
if (!hideCause && slotHidden) {
|
|
setSlotHidden(false)
|
|
const timer = window.setTimeout(() => {
|
|
if (alive) setFrozen(null)
|
|
}, 200)
|
|
return () => {
|
|
alive = false
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
return () => {
|
|
alive = false
|
|
}
|
|
}, [hideCause, slotHidden, activeTab?.tabId])
|
|
|
|
return (
|
|
<div className="flex h-full min-w-0 flex-1 flex-col bg-inset" aria-label="内置浏览器">
|
|
{/* 页签栏:˅ 列表(打开/最近关闭)+ 页签(右键菜单)+ 新建 */}
|
|
<div className="flex h-9 shrink-0 items-center gap-1 border-b border-line bg-surface px-1.5">
|
|
<div className="relative shrink-0">
|
|
<button
|
|
type="button"
|
|
aria-label="标签页列表"
|
|
aria-expanded={tabListOpen}
|
|
title="标签页列表"
|
|
onClick={() => setTabListOpen((v) => !v)}
|
|
className={`flex size-7 items-center justify-center rounded transition-colors ${
|
|
tabListOpen ? 'bg-hover text-ink-2' : 'text-ink-3 hover:bg-hover hover:text-ink-2'
|
|
}`}
|
|
>
|
|
<LuChevronDown size={13} />
|
|
</button>
|
|
|
|
{tabListOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-20" onClick={() => setTabListOpen(false)} />
|
|
<div className="absolute left-0 top-9 z-30 max-h-[320px] w-60 overflow-y-auto rounded-lg border border-line bg-surface py-1.5 shadow-overlay">
|
|
<div className="px-3 pb-1 pt-1 text-[11px] text-ink-3">打开的标签页</div>
|
|
{(state?.tabs ?? []).length === 0 && (
|
|
<div className="px-3 py-1 text-[11.5px] text-ink-3">无打开的标签页</div>
|
|
)}
|
|
{(state?.tabs ?? []).map((tab) => (
|
|
<div
|
|
key={tab.tabId}
|
|
onClick={() => props.onSelectTab(tab.tabId)}
|
|
className="group flex cursor-default items-center gap-2 px-2 py-1.5 hover:bg-hover"
|
|
>
|
|
{tab.loading ? (
|
|
<LuRotateCw size={12} className="shrink-0 animate-spin text-ink-3" />
|
|
) : (
|
|
<LuGlobe size={12} className="shrink-0 text-ink-3" />
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate text-[12px] text-ink-2">
|
|
{tab.title || tab.url || '新标签页'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label="关闭标签页"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
props.onCloseTab(tab.tabId)
|
|
}}
|
|
className="shrink-0 rounded p-0.5 text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2"
|
|
>
|
|
<LuX size={11} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
{(state?.tabs ?? []).length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
props.onCloseAllTabs()
|
|
setTabListOpen(false)
|
|
}}
|
|
className="mt-0.5 flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-[12px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
|
>
|
|
<LuX size={11} className="shrink-0" />
|
|
关闭全部标签页
|
|
</button>
|
|
)}
|
|
|
|
{(state?.recentClosed?.length ?? 0) > 0 && (
|
|
<>
|
|
<div className="my-1 border-t border-line-soft" />
|
|
<div className="px-3 pb-1 text-[11px] text-ink-3">最近关闭的标签页</div>
|
|
{state?.recentClosed.map((item, i) => (
|
|
<button
|
|
key={`${i}-${item.url}`}
|
|
type="button"
|
|
onClick={() => {
|
|
setTabListOpen(false)
|
|
void Promise.resolve(props.onNewTab(item.url)).catch(() => undefined)
|
|
}}
|
|
className="flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-hover"
|
|
>
|
|
<LuGlobe size={12} className="shrink-0 text-ink-3" />
|
|
<span className="min-w-0 flex-1 truncate text-[12px] text-ink-2">{item.title}</span>
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex h-full min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
|
{(state?.tabs ?? []).map((tab) => (
|
|
<button
|
|
key={tab.tabId}
|
|
type="button"
|
|
onClick={() => props.onSelectTab(tab.tabId)}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault()
|
|
setTabMenu({
|
|
x: Math.min(e.clientX, window.innerWidth - 170),
|
|
y: Math.min(e.clientY, window.innerHeight - 110),
|
|
tabId: tab.tabId
|
|
})
|
|
}}
|
|
className={`group flex h-7 max-w-44 min-w-20 shrink-0 items-center gap-1 rounded px-2 text-left text-[11px] transition-colors ${
|
|
tab.tabId === state?.activeTabId
|
|
? 'bg-accent-tint text-accent-ink'
|
|
: 'text-ink-3 hover:bg-hover hover:text-ink-2'
|
|
}`}
|
|
>
|
|
{tab.loading && <LuRotateCw size={10} className="shrink-0 animate-spin" />}
|
|
<span className="min-w-0 flex-1 truncate">{tab.title || tab.url || '新标签页'}</span>
|
|
<span
|
|
role="button"
|
|
tabIndex={-1}
|
|
aria-label="关闭标签页"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
props.onCloseTab(tab.tabId)
|
|
}}
|
|
className="hidden shrink-0 rounded p-0.5 hover:bg-hover-2 group-hover:block"
|
|
>
|
|
<LuX size={10} />
|
|
</span>
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
aria-label="新建标签页"
|
|
onClick={() => props.onNewTab()}
|
|
disabled={Boolean(riskBanner)}
|
|
className="flex size-7 shrink-0 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2 disabled:opacity-40"
|
|
>
|
|
<LuPlus size={13} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 页签右键菜单 */}
|
|
{tabMenu && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-30"
|
|
onClick={() => setTabMenu(null)}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault()
|
|
setTabMenu(null)
|
|
}}
|
|
/>
|
|
<div
|
|
className="fixed z-40 w-40 rounded-lg border border-line bg-surface py-1 shadow-overlay"
|
|
style={{ left: tabMenu.x, top: tabMenu.y }}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
props.onCloseTab(tabMenu.tabId)
|
|
setTabMenu(null)
|
|
}}
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover"
|
|
>
|
|
<LuX size={11} className="shrink-0" />
|
|
关闭标签页
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={(state?.tabs.length ?? 0) <= 1}
|
|
onClick={() => {
|
|
for (const t of state?.tabs ?? []) {
|
|
if (t.tabId !== tabMenu.tabId) props.onCloseTab(t.tabId)
|
|
}
|
|
setTabMenu(null)
|
|
}}
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover disabled:cursor-default disabled:text-ink-3 disabled:hover:bg-transparent"
|
|
>
|
|
<LuX size={11} className="shrink-0" />
|
|
关闭其他标签页
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 工具条 */}
|
|
<div className="flex h-10 shrink-0 items-center gap-1 border-b border-line bg-surface px-1.5">
|
|
<button
|
|
type="button"
|
|
aria-label="后退"
|
|
onClick={props.onGoBack}
|
|
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
|
>
|
|
<LuArrowLeft size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="前进"
|
|
onClick={props.onGoForward}
|
|
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
|
>
|
|
<LuArrowRight size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="刷新"
|
|
onClick={props.onReload}
|
|
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
|
>
|
|
<LuRotateCw size={13} />
|
|
</button>
|
|
<div className="mx-1 flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded border border-line bg-inset px-2 focus-within:border-accent focus-within:bg-surface">
|
|
<LuGlobe size={11} className="shrink-0 text-ink-3" />
|
|
<input
|
|
ref={addressRef}
|
|
value={address}
|
|
onChange={(e) => setAddress(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') submitAddress()
|
|
if (e.key === 'Escape') addressRef.current?.blur()
|
|
}}
|
|
placeholder="输入网址或搜索…"
|
|
spellCheck={false}
|
|
className="min-w-0 flex-1 bg-transparent text-[12px] text-ink-2 outline-none placeholder:text-ink-3"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setProxyOpen(true)}
|
|
title="代理设置"
|
|
aria-expanded={proxyOpen}
|
|
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
|
>
|
|
<LuNetwork size={13} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 风险告知(首次使用门禁,Agent 与用户操作都会被拦截直到确认) */}
|
|
{riskBanner && (
|
|
<div className="flex shrink-0 items-start gap-2 border-b border-orange/40 bg-orange-tint px-3 py-2.5 text-[11.5px] leading-relaxed text-orange">
|
|
<LuCircleAlert size={14} className="mt-0.5 shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<p>
|
|
Agent 将以你的身份操作网页。平台可能将自动化行为识别为异常,导致验证码、限流或账号风控;请勿在受管浏览器中登录支付等高敏感账号。
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={props.onAcknowledgeRisk}
|
|
className="mt-1.5 rounded bg-orange px-2.5 py-1 text-[11px] font-medium text-page transition-opacity hover:opacity-90"
|
|
>
|
|
我已了解,开始使用
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 页面区域:原生 WebContentsView 对齐到这里的矩形。
|
|
* 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白。
|
|
* 左内缩 4px:原生视图盖住一切 DOM,必须让出侧面板左缘的拖拽把手 */}
|
|
<div className="relative min-h-0 flex-1 bg-page pl-1">
|
|
{activeTab && !slotHidden ? (
|
|
<BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} />
|
|
) : activeTab ? (
|
|
frozen ? (
|
|
<img src={frozen} alt="" aria-hidden className="h-full w-full object-cover" draggable={false} />
|
|
) : (
|
|
<div className="h-full bg-surface" />
|
|
)
|
|
) : (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
|
<LuGlobe size={28} className="text-ink-3" />
|
|
{riskBanner && <p className="text-[12px] text-ink-3">确认上方风险告知后即可打开网页</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Agent 操作轨迹 */}
|
|
{state?.trace && (
|
|
<div className="shrink-0 truncate border-t border-line bg-surface px-3 py-1.5 text-[10.5px] text-ink-3">
|
|
{state.trace}
|
|
</div>
|
|
)}
|
|
|
|
{/* 代理设置浮层(覆盖面板,出现时原生视图已隐藏) */}
|
|
{proxyOpen && <ProxyOverlay onClose={() => setProxyOpen(false)} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---- 浮层薄壳:标题栏 + 关闭;表单本体在共用组件(设置页二级页同样复用) ----
|
|
|
|
function ProxyOverlay({ onClose }: { onClose: () => void }): JSX.Element {
|
|
return (
|
|
<div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]">
|
|
<div className="flex h-10 shrink-0 items-center justify-between border-b border-line px-3">
|
|
<div className="text-[12.5px] font-medium text-ink-2">代理设置</div>
|
|
<button
|
|
type="button"
|
|
aria-label="关闭"
|
|
onClick={onClose}
|
|
className="flex size-6 items-center justify-center rounded text-ink-3 hover:bg-hover hover:text-ink-2"
|
|
>
|
|
<LuX size={13} />
|
|
</button>
|
|
</div>
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
<BrowserProxyForm />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|