feat(settings): 设置页右边常驻服务器浏览器的实时画面
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

用途只有一个:**过验证码**。抖音/小红书弹滑块时采集会静默失败(网关回 200 + 空
body,看起来像「这个博主没作品」),人在这块画面里点一下就好,不用跑到机器跟前。

走 noVNC(宿主机上 websockify 起的静态站),iframe 嵌进来而不是 window.open ——
要的就是「抬眼就能看见」。主机名取自当前页面,换 IP/域名访问都能用。

noVNC 不发 X-Frame-Options,所以嵌得进来(curl -I 确认过)。

密码**没有写进代码**:noVNC 地址里的 password 参数留空,等宿主机上那个密码去掉
之后就是点开即连。写死在这里会进 git 历史、还会打进前端产物。
This commit is contained in:
2026-10-11 08:16:41 +08:00
parent f4373c098e
commit df1246ab79
2 changed files with 107 additions and 2 deletions
@@ -0,0 +1,97 @@
import { useMemo, useState } from 'react'
import { ExternalLink, MonitorPlay, RotateCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
/**
* 服务器上那个浏览器的**实时画面** —— 就是定时任务接管的那一个。
*
* 用途只有一个:**过验证码**。抖音/小红书弹滑块或短信验证时,采集会静默失败
* (网关回 200 + 空 body,看起来像「这个博主没作品」),人在这个画面里点一下就好了,
* 不用跑到机器跟前。
*
* 走 noVNC(websockify 在宿主机上起的静态站)。
*
* **别把它当成「看采集结果」的地方** —— 结果是作品栏和报表,这里是浏览器本身。
*/
// websockify 监听的端口。和 18051 这个应用不是同一个,所以**必须**是绝对地址。
const VNC_PORT = 6080
// 特意**留空**:服务端那边密码去掉之后,地址里不传 password 就不会弹框。
//
// 千万不要把密码写死在这里 —— 它会进 git 历史,还会打进前端产物,而任何能打开
// 这个页面的人都能看到。要密码就打进 noVNC 自己的框里,或者去服务端把密码去掉。
const VNC_PASSWORD = ''
/** 拼 noVNC 的地址。主机名取自当前页面,所以换域名/IP 访问都能用。 */
function viewerUrl(host: string, password: string): string {
const params = new URLSearchParams({
autoconnect: '1',
// 让远程画面缩放到容器大小,而不是撑出滚动条。
resize: 'scale',
// 掉线自己重连,省得每次断线都要手动点。
reconnect: '1',
reconnect_delay: '2000',
})
if (password) params.set('password', password)
return `http://${host}:${VNC_PORT}/vnc.html?${params.toString()}`
}
export function RemoteBrowserPanel() {
const [nonce, setNonce] = useState(0)
const url = useMemo(
() => viewerUrl(window.location.hostname, VNC_PASSWORD),
// nonce 变化 = 用户点了「重连」,靠换 key 强制 iframe 重新加载。
// eslint-disable-next-line react-hooks/exhaustive-deps
[nonce],
)
return (
<div className="flex flex-col h-full min-h-0 rounded-lg border border-cyber-border-subtle bg-cyber-bg-secondary/40 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-cyber-border-subtle bg-cyber-bg-tertiary/60 flex-shrink-0">
<MonitorPlay className="w-3.5 h-3.5 text-cyber-neon-cyan flex-shrink-0" />
<span className="text-[11px] font-mono text-cyber-text-primary">远程浏览器</span>
<span className="text-[10px] font-mono text-cyber-text-muted truncate">
采集用的那个 · 弹验证码时在这里点
</span>
<div className="ml-auto flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5"
title="重新连接"
onClick={() => setNonce((value) => value + 1)}
>
<RotateCw className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5"
title="在新窗口打开"
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className="w-3 h-3" />
</Button>
</div>
</div>
{/* iframe 而不是 window.open:要的就是「抬眼就能看见」,而不是再开一个标签页。
noVNC 那边不发 X-Frame-Options,所以嵌得进来。 */}
<iframe
key={nonce}
src={url}
title="远程浏览器"
className="flex-1 min-h-0 w-full bg-black"
allow="clipboard-read; clipboard-write"
/>
<p className="px-3 py-1.5 text-[10px] font-mono text-cyber-text-muted border-t border-cyber-border-subtle flex-shrink-0">
连不上就说明宿主机上的 websockify 没在跑(`{window.location.hostname}:{VNC_PORT}`)。
这个画面里就是服务器的浏览器本人,能看到你所有登录态 —— 只在局域网里用。
</p>
</div>
)
}
+10 -2
View File
@@ -4,6 +4,7 @@ import { KeyRound, QrCode, Save } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { CookiePanel } from '@/components/monitor/CookiePanel' import { CookiePanel } from '@/components/monitor/CookiePanel'
import { QrLoginPanel } from '@/components/monitor/QrLoginPanel' import { QrLoginPanel } from '@/components/monitor/QrLoginPanel'
import { RemoteBrowserPanel } from '@/components/settings/RemoteBrowserPanel'
import { Section, SettingField } from '@/components/settings/SettingFields' import { Section, SettingField } from '@/components/settings/SettingFields'
import { useSettings, useUpdateSettings } from '@/hooks/useMonitor' import { useSettings, useUpdateSettings } from '@/hooks/useMonitor'
import { useCurrentPlatform } from '@/hooks/usePlatform' import { useCurrentPlatform } from '@/hooks/usePlatform'
@@ -55,8 +56,9 @@ export function SettingsView({ onNavigate }: { onNavigate?: (view: AppView) => v
const label = capability?.label ?? platform const label = capability?.label ?? platform
return ( return (
<div className="flex-1 overflow-y-auto terminal-scroll pr-1"> <div className="flex-1 flex gap-4 min-h-0">
<div className="max-w-3xl space-y-3 pb-6"> {/* max-w-3xl 是为了让设置项保持好读的行宽;右边那块画面把剩下的宽度拿走。 */}
<div className="flex-1 max-w-3xl overflow-y-auto terminal-scroll pr-1 min-w-0 space-y-3 pb-6">
<div className="rounded-lg border border-cyber-neon-cyan/30 bg-cyber-neon-cyan/5 px-4 py-2"> <div className="rounded-lg border border-cyber-neon-cyan/30 bg-cyber-neon-cyan/5 px-4 py-2">
<p className="text-[11px] font-mono text-cyber-text-secondary"> <p className="text-[11px] font-mono text-cyber-text-secondary">
当前平台: 当前平台:
@@ -139,6 +141,12 @@ export function SettingsView({ onNavigate }: { onNavigate?: (view: AppView) => v
</div> </div>
)} )}
</div> </div>
{/* 右边常驻一块服务器浏览器的实时画面 —— 弹验证码时要能抬眼就看见、伸手就能点,
而不是「切页面 → 找入口 → 等加载」。宽度固定,左边那列自己让位。 */}
<aside className="w-[380px] xl:w-[460px] 2xl:w-[560px] flex-shrink-0 min-h-0">
<RemoteBrowserPanel />
</aside>
</div> </div>
) )
} }