fix: 扫码登录状态可独立查询 + 趋势图改回自适应纵轴
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

【登录反馈】实测这台浏览器 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:
2026-10-07 15:42:58 +08:00
parent e608b51210
commit 6eff6fcc83
8 changed files with 578 additions and 270 deletions
+55 -20
View File
@@ -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>
)
+90 -44
View File
@@ -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>
+28
View File
@@ -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() {
+4
View File
@@ -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 }),
+20
View File
@@ -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 {