Files
inkreach-agent/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx
T

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>
)
}