- runner: enable_cdp_mode 从硬编码 False 改为系统设置 cdp_enabled。服务器部署下爬虫接管已开启远程调试的 Chrome(默认 9222),复用其 profile 登录态;本机桌面默认仍为关,行为不变 - qrlogin: 新增 CDP 扫码登录。Chrome 在服务器上跑于 Xvfb,show_qrcode 依赖的 PIL 桌面看图程序不存在,二维码无处可显示;改为经 CDP 从页面取出二维码交给 WebUI 渲染。刻意复用 browser.contexts[0](新建 context 是无痕 profile,扫了也白扫),且绝不调用 browser.close()(会连带关掉操作者自己的 Chrome) - webui: 设置页新增扫码面板,替换原本跳到采集页看终端二维码的入口 - Dockerfile / .dockerignore / docker-compose.yml: 服务器部署。host 网络是必需而非图省事——容器里 127.0.0.1:9222 必须落到宿主机回环 - UPSTREAM.md: 补充 gitcode 镜像,用于 GitHub 大包传输必断时补历史
155 lines
5.7 KiB
TypeScript
155 lines
5.7 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { useQueryClient } from '@tanstack/react-query'
|
||
import { AlertTriangle, CheckCircle2, Loader2, QrCode, RefreshCw, X } from 'lucide-react'
|
||
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { useCancelQrLogin, useQrLoginStatus, useStartQrLogin } from '@/hooks/useMonitor'
|
||
import { useCurrentPlatform } from '@/hooks/usePlatform'
|
||
|
||
/**
|
||
* Scan-to-login for a host with no display.
|
||
*
|
||
* The crawler's own QR flow prints the code into the terminal via PIL's
|
||
* `Image.show()`, which needs a desktop image viewer. On a server Chrome runs
|
||
* under Xvfb and there is no such viewer, so the backend reads the code out of
|
||
* that same browser over CDP and hands it here instead.
|
||
*
|
||
* That browser is the one monitor runs attach to, which is the point: scanning
|
||
* once leaves the login in the profile that every later unattended run reuses.
|
||
*/
|
||
export function QrLoginPanel() {
|
||
const { capability, platform } = useCurrentPlatform()
|
||
const queryClient = useQueryClient()
|
||
const [polling, setPolling] = useState(false)
|
||
|
||
const { data: state } = useQrLoginStatus(polling)
|
||
const start = useStartQrLogin()
|
||
const cancel = useCancelQrLogin()
|
||
|
||
const label = capability?.label ?? platform
|
||
const status = state?.status ?? 'idle'
|
||
|
||
// Stop polling the moment the outcome is known, and refresh the cookie panel:
|
||
// a completed scan is what makes it start reporting a healthy login.
|
||
useEffect(() => {
|
||
if (status === 'waiting' || status === 'idle') return
|
||
setPolling(false)
|
||
if (status === 'success') {
|
||
queryClient.invalidateQueries({ queryKey: ['monitorCookie'] })
|
||
}
|
||
}, [status, queryClient])
|
||
|
||
const begin = () => {
|
||
setPolling(true)
|
||
start.mutate()
|
||
}
|
||
|
||
const busy = start.isPending || cancel.isPending
|
||
|
||
return (
|
||
<div className="rounded-lg glass-panel float-panel p-4 space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<QrCode className="w-4 h-4 text-cyber-neon-cyan" />
|
||
<span className="font-mono text-xs tracking-wider text-cyber-text-primary">
|
||
{label}扫码登录
|
||
</span>
|
||
{status === 'waiting' && (
|
||
<Badge variant="warning" className="text-[10px]">
|
||
等待扫码
|
||
</Badge>
|
||
)}
|
||
{status === 'success' && (
|
||
<Badge variant="success" className="text-[10px]">
|
||
已登录
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
{status === 'waiting' && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
setPolling(false)
|
||
cancel.mutate()
|
||
}}
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{status === 'waiting' && state?.image && (
|
||
<div className="flex items-start gap-4">
|
||
{/* The code is a data: URL straight from the page, so nothing is
|
||
fetched from a third party and no file is written server-side. */}
|
||
<img
|
||
src={state.image}
|
||
alt="登录二维码"
|
||
className="w-44 h-44 rounded-md border border-cyber-border-DEFAULT bg-white p-1"
|
||
/>
|
||
<div className="space-y-2 pt-1">
|
||
<p className="text-[11px] font-mono text-cyber-text-secondary leading-relaxed">
|
||
用<span className="text-cyber-neon-cyan">{label} App</span>扫码。
|
||
扫码成功后登录态会写入服务器上那台 Chrome 的 profile,
|
||
之后定时监控无需再登录。
|
||
</p>
|
||
<p className="text-[11px] font-mono text-cyber-text-muted">
|
||
剩余 <span className="text-cyber-neon-cyan">{state.expires_in}</span> 秒
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{status === 'waiting' && !state?.image && (
|
||
<p className="flex items-center gap-2 text-[11px] font-mono text-cyber-text-muted">
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
正在从浏览器取二维码…
|
||
</p>
|
||
)}
|
||
|
||
{status === 'success' && (
|
||
<p className="flex items-start gap-2 text-[11px] font-mono text-cyber-neon-green leading-relaxed">
|
||
<CheckCircle2 className="w-3 h-3 mt-0.5 shrink-0" />
|
||
{state?.message || '登录成功'}
|
||
</p>
|
||
)}
|
||
|
||
{(status === 'error' || status === 'expired') && (
|
||
<div className="space-y-2">
|
||
<p className="flex items-start gap-2 text-[11px] font-mono text-cyber-neon-orange leading-relaxed">
|
||
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
|
||
{state?.message || '获取二维码失败'}
|
||
</p>
|
||
<Button variant="outline" size="sm" disabled={busy} onClick={begin}>
|
||
<RefreshCw className="w-3 h-3 mr-1" />
|
||
重新获取
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{status === 'idle' && (
|
||
<div className="space-y-2">
|
||
<p className="text-[11px] font-mono text-cyber-text-muted leading-relaxed">
|
||
走 CDP 接管服务器上已开启远程调试的 Chrome,把二维码取回来显示在这里。
|
||
需要先在「系统设置」里打开
|
||
<span className="text-cyber-neon-cyan">接管已有 Chrome(CDP)</span>,
|
||
并确保那台 Chrome 正以 <span className="text-cyber-neon-cyan">9222</span> 端口运行。
|
||
</p>
|
||
<Button size="sm" disabled={busy} onClick={begin}>
|
||
{start.isPending ? (
|
||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||
) : (
|
||
<QrCode className="w-3 h-3 mr-1" />
|
||
)}
|
||
获取二维码
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|