fix: 扫码登录状态可独立查询 + 趋势图改回自适应纵轴
【登录反馈】实测这台浏览器 loggedIn=true,其实早就登录成功了;看不到反馈是判据和设计的问题: 1. 判据不可靠。原先靠 web_session 的值变化判断——对照组显示:一个全新的空 profile 首次访问小红书就会被发一个 web_session,所以「有这个 cookie」什么都证明不了。可信信号是页面自己的 __INITIAL_STATE__.user.loggedIn,但它是 Vue 响应式引用,必须 .value 解包(这就是前面探针读到 [object Object] 和 None 的原因)。 2. 状态绑死在临时会话上。扫码会话是内存状态,进程一重启就没(部署、崩溃都算),面板于是悄悄退回初始态——一次成功扫码看起来像什么都没发生。 改法不是让会话活得久,而是把「登没登录」变成随时可查、与会话无关: - 新增 GET /api/monitor/login/state,直接问浏览器要答案,带 5 秒缓存;force=true 先重载页面再读,用于状态陈旧 - qrlogin 改为常驻 Playwright 客户端 + 复用同一个标签页,并在重启后认领浏览器里已存在的 xhs 标签页,避免堆孤儿页 - 把「读不到状态」与「未登录」分开——前者显示具体错误,不再悄悄显示成未登录 - 面板顶部常驻显示登录态与昵称,带「重新检测」按钮;扫码成功后自动翻转 【趋势图】上一轮改过头了。dataviz 规范里没有「折线图必须从 0 起」这条——基线相关的条文全是讲柱状图的(柱状图用长度编码数值,不从 0 起比例就是错的;折线图用位置编码,轴只需如实框住数据)。改回自适应,保留上一轮修好的左侧刻度栏让范围始终可见;步长收敛到 1/2/5×10ⁿ,全平序列撑开一档避免除零。
This commit is contained in:
@@ -19,20 +19,53 @@ const PAD_RIGHT = 16
|
||||
const PAD_TOP = 14
|
||||
const PAD_BOTTOM = 22
|
||||
|
||||
/**
|
||||
* 把纵轴上界向上取到一个好读的数(1 / 1.2 / 1.5 / 2 / …)。
|
||||
*
|
||||
* 纵轴刻意从 0 起。原先按 [最小值, 最大值] 自适应,于是 491 -> 506 这点变化被撑满
|
||||
* 整个图高、看着像暴涨;而且上下两个刻度就是 "506" 和 "491" 两个几乎一样的数,
|
||||
* 没有 0 做参照,根本读不出量级。
|
||||
*/
|
||||
const NICE_STEPS = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]
|
||||
const TICK_COUNT = 3
|
||||
|
||||
function niceMax(value: number): number {
|
||||
/** 把步长收敛到 1 / 2 / 5 × 10ⁿ,刻度才会落在好读的数上。 */
|
||||
function niceNumber(value: number, round: boolean): number {
|
||||
if (value <= 0) return 1
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value))
|
||||
const step = NICE_STEPS.find((candidate) => value / magnitude <= candidate) ?? 10
|
||||
return step * magnitude
|
||||
const exponent = Math.floor(Math.log10(value))
|
||||
const fraction = value / 10 ** exponent
|
||||
let nice: number
|
||||
if (round) {
|
||||
nice = fraction < 1.5 ? 1 : fraction < 3 ? 2 : fraction < 7 ? 5 : 10
|
||||
} else {
|
||||
nice = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10
|
||||
}
|
||||
return nice * 10 ** exponent
|
||||
}
|
||||
|
||||
/**
|
||||
* 给折线图选一个纵轴范围。
|
||||
*
|
||||
* **刻意不从 0 起。** 柱状图用「长度」编码数值,基线不为 0 比例就是错的;折线图用
|
||||
* 「位置」编码,轴只需要如实框住数据 —— 这正是让 491 → 506 这段变化看得见的原因,
|
||||
* 否则它会贴着 0–600 的底边变成一条直线。
|
||||
*
|
||||
* 代价是纵轴不再是 0,所以刻度必须落在左侧栏里、显示真实数值,让读图的人随时知道
|
||||
* 范围是多少。这一点在上一轮已经修好。
|
||||
*/
|
||||
function niceAxis(values: number[]): { min: number; max: number; ticks: number[] } {
|
||||
const dataMin = Math.min(...values)
|
||||
const dataMax = Math.max(...values)
|
||||
|
||||
// 全平的一组数没有跨度可缩放,给它一个名义区间,让线落在图中而不是贴边。
|
||||
const span = dataMax - dataMin || Math.max(1, Math.abs(dataMax) * 0.05 || 1)
|
||||
|
||||
const step = niceNumber(span / (TICK_COUNT - 1), true)
|
||||
let min = Math.floor(dataMin / step) * step
|
||||
let max = Math.ceil(dataMax / step) * step
|
||||
if (min === max) {
|
||||
// 取整后塌成一点会除零,撑开一档。
|
||||
min -= step
|
||||
max += step
|
||||
}
|
||||
|
||||
const ticks: number[] = []
|
||||
for (let value = min; value <= max + step / 2; value += step) {
|
||||
ticks.push(Math.round(value))
|
||||
}
|
||||
return { min, max, ticks }
|
||||
}
|
||||
|
||||
interface NoteTrendChartProps {
|
||||
@@ -81,22 +114,24 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp
|
||||
if (points.length < 2 || width <= 0) return null
|
||||
|
||||
const values = points.map((point) => point[metric] as number)
|
||||
const axisMax = niceMax(Math.max(...values))
|
||||
const { min, max, ticks } = niceAxis(values)
|
||||
|
||||
const innerW = Math.max(1, width - PAD_LEFT - PAD_RIGHT)
|
||||
const innerH = VIEW_H - PAD_TOP - PAD_BOTTOM
|
||||
const yFor = (value: number) =>
|
||||
PAD_TOP + innerH * (1 - (value - min) / (max - min))
|
||||
|
||||
const xy = points.map((point, index) => {
|
||||
const value = point[metric] as number
|
||||
return {
|
||||
x: PAD_LEFT + (index / (points.length - 1)) * innerW,
|
||||
y: PAD_TOP + innerH - (value / axisMax) * innerH,
|
||||
y: yFor(value),
|
||||
value,
|
||||
point,
|
||||
}
|
||||
})
|
||||
|
||||
return { xy, axisMax, innerW, innerH }
|
||||
return { xy, ticks, yFor, innerW, innerH }
|
||||
}, [points, metric, width])
|
||||
|
||||
if (isLoading) {
|
||||
@@ -162,10 +197,10 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp
|
||||
>
|
||||
{/* 刻度线画在 0 / 中值 / 上界上,标签就贴在对应位置 —— 不再浮在图面上,
|
||||
也就不会出现两个数挤在一起读成一个数的情况。 */}
|
||||
{[0, 0.5, 1].map((ratio) => {
|
||||
const y = PAD_TOP + geometry.innerH * ratio
|
||||
{geometry.ticks.map((tick) => {
|
||||
const y = geometry.yFor(tick)
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={PAD_LEFT}
|
||||
x2={width - PAD_RIGHT}
|
||||
@@ -183,7 +218,7 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp
|
||||
fill="rgb(var(--cyber-text-muted))"
|
||||
style={{ fontFamily: 'ui-monospace, SFMono-Regular, monospace' }}
|
||||
>
|
||||
{formatCount(Math.round(geometry.axisMax * (1 - ratio)))}
|
||||
{formatCount(tick)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
@@ -256,7 +291,7 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp
|
||||
)}
|
||||
|
||||
<p className="text-[10px] font-mono text-cyber-text-muted">
|
||||
共 {points.length} 个数据点,每轮采集记录一次快照 · 纵轴自 0 起
|
||||
共 {points.length} 个数据点,每轮采集记录一次快照 · 纵轴范围见左侧刻度
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, CheckCircle2, Loader2, QrCode, RefreshCw, X } from 'lucide-react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
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 {
|
||||
useCancelQrLogin,
|
||||
useLoginState,
|
||||
useQrLoginStatus,
|
||||
useRecheckLogin,
|
||||
useStartQrLogin,
|
||||
} from '@/hooks/useMonitor'
|
||||
import { useCurrentPlatform } from '@/hooks/usePlatform'
|
||||
|
||||
/**
|
||||
* Scan-to-login for a host with no display.
|
||||
* 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 same browser over CDP and hands it here.
|
||||
*
|
||||
* 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.
|
||||
* **The login state is shown first and independently of the scan.** It comes from
|
||||
* the browser's own profile, so it stays true across a server restart — the QR
|
||||
* session does not, and reporting "did it work?" from a value that a redeploy
|
||||
* silently erases is how a successful scan ends up looking like nothing happened.
|
||||
*/
|
||||
export function QrLoginPanel() {
|
||||
const { capability, platform } = useCurrentPlatform()
|
||||
@@ -24,12 +40,19 @@ export function QrLoginPanel() {
|
||||
const [polling, setPolling] = useState(false)
|
||||
|
||||
const { data: state } = useQrLoginStatus(polling)
|
||||
const { data: login } = useLoginState(polling)
|
||||
const start = useStartQrLogin()
|
||||
const cancel = useCancelQrLogin()
|
||||
const recheck = useRecheckLogin()
|
||||
|
||||
const label = capability?.label ?? platform
|
||||
const status = state?.status ?? 'idle'
|
||||
|
||||
// The browser's own answer wins over the session's: the session is memory, the
|
||||
// profile is not.
|
||||
const loggedIn = login?.logged_in ?? state?.logged_in ?? false
|
||||
const nickname = login?.nickname ?? state?.nickname ?? null
|
||||
|
||||
// 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(() => {
|
||||
@@ -37,6 +60,7 @@ export function QrLoginPanel() {
|
||||
setPolling(false)
|
||||
if (status === 'success') {
|
||||
queryClient.invalidateQueries({ queryKey: ['monitorCookie'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['monitorLoginState'] })
|
||||
}
|
||||
}, [status, queryClient])
|
||||
|
||||
@@ -45,43 +69,53 @@ export function QrLoginPanel() {
|
||||
start.mutate()
|
||||
}
|
||||
|
||||
const busy = start.isPending || cancel.isPending
|
||||
const busy = start.isPending || cancel.isPending || recheck.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}扫码登录
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<KeyRound className="w-4 h-4 text-cyber-neon-cyan flex-shrink-0" />
|
||||
<span className="font-mono text-xs tracking-wider text-cyber-text-primary flex-shrink-0">
|
||||
{label}登录态
|
||||
</span>
|
||||
{status === 'waiting' && (
|
||||
<Badge variant="warning" className="text-[10px]">
|
||||
等待扫码
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
{loggedIn ? (
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
已登录
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="warning" className="text-[10px]">
|
||||
未登录
|
||||
</Badge>
|
||||
)}
|
||||
{loggedIn && nickname && (
|
||||
<span className="truncate text-[10px] font-mono text-cyber-text-secondary">
|
||||
{nickname}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === 'waiting' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setPolling(false)
|
||||
cancel.mutate()
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => recheck.mutate()}
|
||||
title="重新加载页面并检测登录态"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 mr-1 ${recheck.isPending ? 'animate-spin' : ''}`} />
|
||||
重新检测
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 读不到状态时要说清楚是"读不到",而不是悄悄显示成"未登录" */}
|
||||
{login?.known === false && (
|
||||
<p className="flex items-start gap-2 text-[10px] font-mono text-cyber-neon-orange leading-relaxed">
|
||||
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
读不到浏览器状态{login.error ? `:${login.error}` : ''}。
|
||||
请确认那台 Chrome 正常、且已打开小红书页面。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'waiting' && state?.image && (
|
||||
<div className="flex items-start gap-4">
|
||||
{/* The code is a data: URL straight from the page, so nothing is
|
||||
@@ -94,12 +128,23 @@ export function QrLoginPanel() {
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setPolling(false)
|
||||
cancel.mutate()
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -134,19 +179,20 @@ export function QrLoginPanel() {
|
||||
{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> 端口运行。
|
||||
{loggedIn
|
||||
? '这台浏览器已是登录状态,定时监控会直接复用它的 profile,无需再扫码。'
|
||||
: '经 CDP 接管服务器上已开启远程调试的 Chrome,把二维码取回来显示在这里。需要先在「系统设置」里打开 接管已有 Chrome(CDP),并确保那台 Chrome 正以 9222 端口运行。'}
|
||||
</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>
|
||||
{!loggedIn && (
|
||||
<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>
|
||||
|
||||
@@ -244,6 +244,34 @@ export function useCancelQrLogin() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the browser is actually signed in.
|
||||
*
|
||||
* Tracked separately from the QR session above: that session lives in the
|
||||
* server's memory and a restart erases it, while the profile it wrote to does
|
||||
* not. Polled slowly when idle and briskly while a code is on screen.
|
||||
*/
|
||||
export function useLoginState(polling: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['monitorLoginState'],
|
||||
queryFn: async () => (await monitorApi.getLoginState()).data,
|
||||
refetchInterval: polling ? 4000 : 30000,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
}
|
||||
|
||||
/** Re-ask after reloading the page, for when the state looks stale. */
|
||||
export function useRecheckLogin() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => monitorApi.getLoginState(true),
|
||||
onSuccess: (response) => {
|
||||
queryClient.setQueryData(['monitorLoginState'], response.data)
|
||||
},
|
||||
onError: (error: Error) => toast.error(`检测失败:${error.message}`),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Settings -------------------------------------------------------------
|
||||
|
||||
export function useSettings() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
MonitorOverview,
|
||||
MonitorRun,
|
||||
MonitorTask,
|
||||
LoginState,
|
||||
PlatformCapability,
|
||||
QrLoginState,
|
||||
ReportResult,
|
||||
@@ -263,6 +264,9 @@ export const monitorApi = {
|
||||
api.post<QrLoginState>('/monitor/login/qr', null, { params: { platform } }),
|
||||
getQrLogin: () => api.get<QrLoginState>('/monitor/login/qr'),
|
||||
cancelQrLogin: () => api.delete<QrLoginState>('/monitor/login/qr'),
|
||||
/** `force` reloads the page first, for a stale-looking state. */
|
||||
getLoginState: (force = false) =>
|
||||
api.get<LoginState>('/monitor/login/state', { params: { force } }),
|
||||
|
||||
getWebhook: () => api.get<WebhookStatus>('/monitor/webhook'),
|
||||
setWebhook: (url: string) => api.post('/monitor/webhook', { url }),
|
||||
|
||||
@@ -196,6 +196,26 @@ export interface QrLoginState {
|
||||
elapsed: number
|
||||
/** Seconds left before the code is written off. */
|
||||
expires_in: number
|
||||
/** Whether the browser reports being signed in right now. */
|
||||
logged_in: boolean
|
||||
nickname: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's own answer to "am I signed in?".
|
||||
*
|
||||
* Asked of the page, not derived from the QR session -- that session lives in the
|
||||
* server's memory and dies on a restart, so tying the answer to it makes a
|
||||
* successful scan look like nothing happened.
|
||||
*
|
||||
* `known` is false when the page could not report (not loaded, browser
|
||||
* unreachable); `logged_in` is then meaningless rather than false.
|
||||
*/
|
||||
export interface LoginState {
|
||||
known: boolean
|
||||
logged_in: boolean
|
||||
nickname: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MonitorOverview {
|
||||
|
||||
Reference in New Issue
Block a user