fix(creator): 扫码成功却一直停在二维码上 + 运营改为左右布局
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

【扫码不完成】根因是判据本身。原先读页面里的 window.__INITIAL_STATE__,而那是
页面加载那一刻的快照:监控那边的同一探针能用,是因为那台浏览器的页面加载时就已经
登录了;而扫码是加载之后才登录的 —— SPA 内部确实登进去了,但初始快照不会翻转,
于是检测永远等不到。

改成拿 cookie 直接问创作者后台 /api/galaxy/user/info 我是谁。实测这个判据很干净:
游客也会拿到 a1(所以签名算得出来),但接口直接回 401 无登录信息;只有真正登录了
才返回 user_id。所以「有 a1」什么都证明不了,后台认了才算。
顺带按 5 秒节流 —— 前端每 2 秒问一次,没必要每次都打后台接口。

【弹窗不关】成功后不自动关闭,停在二维码上会让人以为没成功。现在显示账号卡片与原话
提示,1.6 秒后自动关闭并提供一个「完成」按钮。

【已完成结果会残留】take_cookie 取走 cookie 就拆会话,而在飞的轮询会看到 _current 为空
回报 idle,把已显示的成功能擦掉。现在把结果记在模块里重复返回,关闭弹窗时清掉 ——
否则下次打开会立刻显示上次的成功。

【布局】按用户要求改成左右两栏(左账号列表、右数据面板),与监控统一,取消二级菜单。
未选过时默认选中第一个,右栏不会一开始就是空的。

测试:tests/test_creator_login.py 新增 7 例,含「游客会话永不完成」「接口不打满每次轮询」
「临时上下文用完必须关掉」。
This commit is contained in:
2026-10-07 16:40:24 +08:00
parent 2f5852e311
commit bef0a4fbde
5 changed files with 466 additions and 259 deletions
@@ -42,19 +42,23 @@ export function AddAccountDialog({
if (!open) return
if (status === 'waiting' || status === 'idle') return
setPolling(false)
if (status === 'success') {
queryClient.invalidateQueries({ queryKey: ['creatorAccounts'] })
}
}, [status, open, queryClient])
if (status !== 'success') return
// 关掉弹窗时把后端那个临时上下文也收掉,别让它挂在浏览器里。
const handleOpenChange = (next: boolean) => {
if (!next) {
setPolling(false)
if (status === 'waiting') cancel.mutate()
}
onOpenChange(next)
}
queryClient.invalidateQueries({ queryKey: ['creatorAccounts'] })
// 自动关掉:停在二维码上会让人以为没成功,而账号其实已经进了列表。
const timer = setTimeout(() => onOpenChange(false), 1600)
return () => clearTimeout(timer)
}, [status, open, queryClient, onOpenChange])
// 关闭时统一收尾:拆掉后端那个临时上下文(别让它挂在操作者的 Chrome 里),
// 并清掉后端记住的结果 —— 否则下次打开弹窗会立刻显示上一次的成功状态。
useEffect(() => {
if (open) return
setPolling(false)
cancel.mutate()
// 只在开关变化时跑,cancel/依赖本身不该触发它。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const begin = () => {
setPolling(true)
@@ -64,7 +68,7 @@ export function AddAccountDialog({
const busy = start.isPending || cancel.isPending
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="font-mono">新增运营账号</DialogTitle>
@@ -108,10 +112,40 @@ export function AddAccountDialog({
)}
{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>
<div className="space-y-3">
<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>
{state?.account && (
<div className="flex items-center gap-2 rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 px-3 py-2">
{state.account.avatar && (
<img
src={state.account.avatar}
alt=""
referrerPolicy="no-referrer"
className="w-8 h-8 rounded-full object-cover"
/>
)}
<div className="min-w-0">
<div className="truncate font-mono text-xs text-cyber-text-primary">
{state.account.nickname}
</div>
<div className="text-[10px] font-mono text-cyber-text-muted">
小红书号 {state.account.red_id || '—'}
</div>
</div>
</div>
)}
{state?.account?.permission_tip && (
<p className="text-[10px] font-mono text-cyber-neon-orange leading-relaxed">
{state.account.permission_tip}
</p>
)}
<Button size="sm" onClick={() => onOpenChange(false)}>
完成
</Button>
</div>
)}
{(status === 'error' || status === 'expired') && (
+198 -209
View File
@@ -1,7 +1,6 @@
import { useState } from 'react'
import {
AlertTriangle,
ArrowLeft,
Briefcase,
Clock,
Plus,
@@ -19,9 +18,9 @@ import {
useDeleteCreatorAccount,
useSyncCreatorAccount,
} from '@/hooks/useCreator'
import { formatCount, formatDateTime, formatRelative } from '@/lib/monitorFormat'
import { useCurrentPlatform } from '@/hooks/usePlatform'
import type { CreatorNote, CreatorPermissionStatus } from '@/types/creator'
import { formatCount, formatDateTime, formatRelative } from '@/lib/monitorFormat'
import type { CreatorAccount, CreatorNote, CreatorPermissionStatus } from '@/types/creator'
import { AddAccountDialog } from './AddAccountDialog'
@@ -44,7 +43,6 @@ const SUMMARY_TILES: Array<{ key: keyof CreatorNote; label: string }> = [
{ key: 'new_followers', label: '涨粉' },
]
/** 比率字段用百分号展示;其余用紧凑计数。 */
function formatRate(value: number | null): string {
return value === null ? '—' : `${value.toFixed(1)}%`
}
@@ -55,92 +53,61 @@ function formatSeconds(value: number | null): string {
return `${Math.floor(value / 60)} 分 ${Math.round(value % 60)} 秒`
}
function AccountList({
selectedId,
/** 左栏里的一行账号。选中项高亮,与监控的任务卡片同一套语言。 */
function AccountRow({
account,
selected,
onSelect,
onAdd,
}: {
selectedId: number | null
onSelect: (id: number) => void
onAdd: () => void
account: CreatorAccount
selected: boolean
onSelect: () => void
}) {
// 有同步在后端跑时,列表要自己刷新,否则状态永远是旧的。
const { data: accounts, isLoading } = useCreatorAccounts(true)
if (isLoading) {
return <p className="py-10 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
}
if (!accounts || accounts.length === 0) {
return (
<div className="flex flex-col items-center gap-3 py-12">
<UserRound className="w-8 h-8 text-cyber-text-muted" />
<p className="text-[11px] font-mono text-cyber-text-muted">
还没有运营账号,扫码添加一个
</p>
<Button size="sm" onClick={onAdd}>
<Plus className="w-3 h-3 mr-1" />
新增账号
</Button>
</div>
)
}
const permission = PERMISSION_LABEL[account.permission_status]
return (
<div className="space-y-2">
{accounts.map((account) => {
const permission = PERMISSION_LABEL[account.permission_status]
return (
<button
key={account.id}
onClick={() => onSelect(account.id)}
className={`w-full flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors ${
selectedId === account.id
? 'border-cyber-neon-cyan/50 bg-cyber-neon-cyan/5'
: 'border-cyber-border-subtle bg-cyber-bg-tertiary/40 hover:border-cyber-border-DEFAULT'
}`}
>
{account.avatar ? (
<img
src={account.avatar}
alt=""
// 小红书图床对带外部 Referer 的请求返回 403,头像同理。
referrerPolicy="no-referrer"
className="w-9 h-9 rounded-full object-cover flex-shrink-0 bg-cyber-bg-tertiary"
/>
) : (
<div className="w-9 h-9 rounded-full bg-cyber-bg-tertiary flex-shrink-0" />
)}
<button
onClick={onSelect}
className={`w-full flex items-center gap-2.5 rounded-lg border px-2.5 py-2 text-left transition-colors ${
selected
? 'border-cyber-neon-cyan/50 bg-cyber-neon-cyan/10'
: 'border-cyber-border-subtle bg-cyber-bg-tertiary/40 hover:border-cyber-border-DEFAULT'
}`}
>
{account.avatar ? (
<img
src={account.avatar}
alt=""
// 小红书图床对带外部 Referer 的请求返回 403,头像同理。
referrerPolicy="no-referrer"
className="w-8 h-8 rounded-full object-cover flex-shrink-0 bg-cyber-bg-tertiary"
/>
) : (
<div className="w-8 h-8 rounded-full bg-cyber-bg-tertiary flex-shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs text-cyber-text-primary">
{account.nickname || '未命名账号'}
</span>
{account.status === 'expired' && (
<Badge variant="warning" className="text-[9px]">
登录失效
</Badge>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 text-[10px] font-mono text-cyber-text-muted">
<span>小红书号 {account.red_id || '—'}</span>
<span>作品 {account.note_count}</span>
<span className={permission.tone}>{permission.text}</span>
</div>
</div>
<span className="flex-shrink-0 text-[9px] font-mono text-cyber-text-muted">
{account.last_synced_at ? formatRelative(account.last_synced_at) : '未同步'}
</span>
</button>
)
})}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-mono text-[11px] text-cyber-text-primary">
{account.nickname || '未命名账号'}
</span>
{account.status === 'expired' && (
<Badge variant="warning" className="text-[9px] px-1 py-0 flex-shrink-0">
失效
</Badge>
)}
</div>
<div className="mt-0.5 flex items-center gap-2 text-[9px] font-mono text-cyber-text-muted">
<span className={permission.tone}>{permission.text}</span>
<span>作品 {account.note_count}</span>
</div>
</div>
</button>
)
}
function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () => void }) {
/** 右栏:所选账号的数据。 */
function AccountPanel({ accountId }: { accountId: number }) {
const { data, isLoading } = useCreatorAccount(accountId)
const sync = useSyncCreatorAccount()
const check = useCheckCreatorAccount()
@@ -154,18 +121,14 @@ function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () =>
const permission = PERMISSION_LABEL[account.permission_status]
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="w-3 h-3 mr-1" />
返回
</Button>
<div className="flex flex-col overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-cyber-border-subtle flex-shrink-0">
{account.avatar && (
<img
src={account.avatar}
alt=""
referrerPolicy="no-referrer"
className="w-8 h-8 rounded-full object-cover"
className="w-7 h-7 rounded-full object-cover"
/>
)}
<span className="font-mono text-xs text-cyber-text-primary">
@@ -174,6 +137,9 @@ function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () =>
<span className="font-mono text-[10px] text-cyber-text-muted">
小红书号 {account.red_id || '—'}
</span>
<span className="ml-1 text-[10px] font-mono text-cyber-text-muted">
上次同步 {account.last_synced_at ? formatRelative(account.last_synced_at) : '从未'}
</span>
<div className="ml-auto flex items-center gap-2">
<Button
@@ -189,110 +155,105 @@ function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () =>
<RefreshCw className={`w-3 h-3 mr-1 ${sync.isPending ? 'animate-spin' : ''}`} />
同步数据
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
remove.mutate(account.id)
onBack()
}}
>
<Button variant="ghost" size="sm" onClick={() => remove.mutate(account.id)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
{/* 权限状态必须显眼:它解释了"为什么没有数据"。后台的原话照抄,不改写。 */}
{account.permission_status !== 'active' && (
<div className="flex items-start gap-2 rounded-md border border-cyber-neon-orange/30 bg-cyber-neon-orange/5 px-3 py-2">
<Clock className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-cyber-neon-orange" />
<div className="text-[11px] font-mono leading-relaxed">
<span className={permission.tone}>{permission.text}</span>
{account.permission_tip && (
<span className="ml-2 text-cyber-text-secondary">{account.permission_tip}</span>
)}
<div className="mt-0.5 text-cyber-text-muted">
数据权限由创作者后台按天开通。在此之前同步会成功但返回 0 条 —— 那是正常的,
不是采集失败。
<div className="flex-1 overflow-y-auto terminal-scroll px-3 py-3 space-y-3">
{/* 权限状态必须显眼:它解释了"为什么没有数据"。后台的原话照抄,不改写。 */}
{account.permission_status !== 'active' && (
<div className="flex items-start gap-2 rounded-md border border-cyber-neon-orange/30 bg-cyber-neon-orange/5 px-3 py-2">
<Clock className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-cyber-neon-orange" />
<div className="text-[11px] font-mono leading-relaxed">
<span className={permission.tone}>{permission.text}</span>
{account.permission_tip && (
<span className="ml-2 text-cyber-text-secondary">{account.permission_tip}</span>
)}
<div className="mt-0.5 text-cyber-text-muted">
数据权限由创作者后台按天开通。在此之前同步会成功但返回 0 条 —— 那是正常的,
不是采集失败。
</div>
</div>
</div>
)}
{account.last_error && (
<p className="flex items-start gap-2 text-[11px] font-mono text-cyber-neon-pink leading-relaxed">
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
{account.last_error}
</p>
)}
<div className="grid grid-cols-4 gap-2 xl:grid-cols-7">
{SUMMARY_TILES.map((tile) => (
<div
key={tile.key}
className="rounded-lg border border-cyber-border-subtle bg-cyber-bg-tertiary/40 px-3 py-2"
>
<div className="text-[10px] font-mono text-cyber-text-muted">{tile.label}</div>
<div className="mt-0.5 font-mono text-sm text-cyber-text-primary">
{formatCount(summary[tile.key as keyof typeof summary] ?? 0)}
</div>
</div>
))}
</div>
)}
{account.last_error && (
<p className="flex items-start gap-2 text-[11px] font-mono text-cyber-neon-pink leading-relaxed">
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
{account.last_error}
</p>
)}
<div className="grid grid-cols-4 gap-2 lg:grid-cols-7">
{SUMMARY_TILES.map((tile) => (
<div
key={tile.key}
className="rounded-lg border border-cyber-border-subtle bg-cyber-bg-tertiary/40 px-3 py-2"
>
<div className="text-[10px] font-mono text-cyber-text-muted">{tile.label}</div>
<div className="mt-0.5 font-mono text-sm text-cyber-text-primary">
{formatCount(summary[tile.key as keyof typeof summary] ?? 0)}
</div>
</div>
))}
</div>
{notes.length === 0 ? (
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
{account.permission_status === 'active'
? '这个账号在所选时间范围内没有作品,或还没同步过 —— 点「同步数据」试试'
: '数据权限生效后即可看到作品数据'}
</p>
) : (
<div className="overflow-x-auto terminal-scroll">
<table className="w-full text-xs font-mono">
<thead className="sticky top-0 bg-cyber-bg-tertiary">
<tr className="text-[10px] text-cyber-text-secondary">
<th className="px-2 py-2 text-left font-normal">作品</th>
{SUMMARY_TILES.map((tile) => (
<th key={tile.key} className="px-2 py-2 text-right font-normal">
{tile.label}
</th>
))}
<th className="px-2 py-2 text-right font-normal">点击率</th>
<th className="px-2 py-2 text-right font-normal">均看时长</th>
<th className="px-2 py-2 text-right font-normal">完播率</th>
</tr>
</thead>
<tbody>
{notes.map((note) => (
<tr key={note.note_id} className="border-t border-cyber-border-subtle">
<td className="max-w-[280px] px-2 py-2">
<div className="truncate text-cyber-text-primary" title={note.title}>
{note.title || note.note_id}
</div>
<div className="text-[9px] text-cyber-text-muted">
{note.publish_time ? formatDateTime(note.publish_time) : '—'}
</div>
</td>
{notes.length === 0 ? (
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
{account.permission_status === 'active'
? '这个账号在所选时间范围内没有作品,或还没同步过 —— 点「同步数据」试试'
: '数据权限生效后即可看到作品数据'}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono">
<thead className="sticky top-0 bg-cyber-bg-tertiary">
<tr className="text-[10px] text-cyber-text-secondary">
<th className="px-2 py-2 text-left font-normal">作品</th>
{SUMMARY_TILES.map((tile) => (
<td key={tile.key} className="px-2 py-2 text-right text-cyber-text-primary">
{formatCount(note[tile.key] as number | null)}
</td>
<th key={tile.key} className="px-2 py-2 text-right font-normal">
{tile.label}
</th>
))}
<td className="px-2 py-2 text-right text-cyber-text-secondary">
{formatRate(note.cover_ctr)}
</td>
<td className="px-2 py-2 text-right text-cyber-text-secondary">
{formatSeconds(note.avg_watch_seconds)}
</td>
<td className="px-2 py-2 text-right text-cyber-text-secondary">
{formatRate(note.completion_rate)}
</td>
<th className="px-2 py-2 text-right font-normal">点击率</th>
<th className="px-2 py-2 text-right font-normal">均看时长</th>
<th className="px-2 py-2 text-right font-normal">完播率</th>
</tr>
))}
</tbody>
</table>
</div>
)}
</thead>
<tbody>
{notes.map((note) => (
<tr key={note.note_id} className="border-t border-cyber-border-subtle">
<td className="max-w-[280px] px-2 py-2">
<div className="truncate text-cyber-text-primary" title={note.title}>
{note.title || note.note_id}
</div>
<div className="text-[9px] text-cyber-text-muted">
{note.publish_time ? formatDateTime(note.publish_time) : '—'}
</div>
</td>
{SUMMARY_TILES.map((tile) => (
<td key={tile.key} className="px-2 py-2 text-right text-cyber-text-primary">
{formatCount(note[tile.key] as number | null)}
</td>
))}
<td className="px-2 py-2 text-right text-cyber-text-secondary">
{formatRate(note.cover_ctr)}
</td>
<td className="px-2 py-2 text-right text-cyber-text-secondary">
{formatSeconds(note.avg_watch_seconds)}
</td>
<td className="px-2 py-2 text-right text-cyber-text-secondary">
{formatRate(note.completion_rate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -300,14 +261,16 @@ function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () =>
/**
* 运营:管理自己的小红书账号。
*
* 与「监控」并列而非其子视图。监控抓的是公开数据(点赞/收藏/评论/分享),这里是
* 创作者后台的运营指标(曝光/观看/完播率/涨粉)。两者数据形状不同、凭据不同、
* 采集方式也不同(那边要浏览器登录态,这边是纯请求)。
* 与「监控」并列而非其子视图,布局也照搬它:左边账号列表、右边数据面板。
* 两者关注的东西不同 —— 监控抓公开数据(点赞/收藏/评论/分享)的每轮差分,
* 这里是创作者后台的运营指标(曝光/观看/完播率/涨粉),凭据与采集方式都不同。
*/
export function OperationView() {
const { platform, capability } = useCurrentPlatform()
const [selectedId, setSelectedId] = useState<number | null>(null)
const [addOpen, setAddOpen] = useState(false)
// 有同步在后端跑时列表要自己刷新,否则状态永远是旧的。
const { data: accounts, isLoading } = useCreatorAccounts(true)
// 运营只对小红书成立:它读的是小红书创作者后台。不挡住的话,切到抖音时这里照样
// 列出小红书的账号 —— 看起来像是"抖音账号",其实平台维度根本没参与。
@@ -321,9 +284,7 @@ export function OperationView() {
<Briefcase className="w-5 h-5 text-cyber-neon-orange" />
</div>
<div>
<h2 className="font-mono text-sm text-cyber-text-primary">
{label} 没有运营模块
</h2>
<h2 className="font-mono text-sm text-cyber-text-primary">{label} 没有运营模块</h2>
<p className="text-[11px] font-mono text-cyber-text-muted">
运营读的是创作者后台的数据,目前只接入了小红书
</p>
@@ -338,35 +299,63 @@ export function OperationView() {
)
}
// 没选过就默认第一个,和监控一样 —— 右栏不该一开始是空的。
const selected =
accounts?.find((account) => account.id === selectedId) ??
(accounts && accounts.length > 0 ? accounts[0] : null)
return (
<div className="flex-1 overflow-y-auto terminal-scroll pr-1">
<div className="max-w-5xl space-y-3 pb-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-cyber-text-primary">运营账号</span>
<span className="text-[10px] font-mono text-cyber-text-muted">
{selectedId === null
? '管理自己的小红书账号,读取创作者后台的曝光/观看/完播等运营数据'
: '点「返回」回到账号列表'}
</span>
</div>
{selectedId === null && (
<div className="flex-1 flex flex-col gap-3 overflow-hidden min-h-0 relative z-10">
<div className="flex-1 flex gap-3 overflow-hidden min-h-0">
{/* 左:账号列表 */}
<div className="w-[300px] flex-shrink-0 flex flex-col gap-3 overflow-hidden">
<div className="flex items-center justify-between flex-shrink-0">
<span className="font-mono text-xs text-cyber-text-primary">运营账号</span>
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus className="w-3 h-3 mr-1" />
新增账号
新增
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto terminal-scroll space-y-2 pr-1">
{isLoading ? (
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
加载中…
</p>
) : !accounts || accounts.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10">
<UserRound className="w-7 h-7 text-cyber-text-muted" />
<p className="text-center text-[11px] font-mono text-cyber-text-muted">
还没有运营账号
<br />
扫码添加一个
</p>
</div>
) : (
accounts.map((account) => (
<AccountRow
key={account.id}
account={account}
selected={selected?.id === account.id}
onSelect={() => setSelectedId(account.id)}
/>
))
)}
</div>
</div>
{selectedId === null ? (
<AccountList
selectedId={selectedId}
onSelect={setSelectedId}
onAdd={() => setAddOpen(true)}
/>
) : (
<AccountDetail accountId={selectedId} onBack={() => setSelectedId(null)} />
)}
{/* 右:所选账号的数据 */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0 rounded-lg glass-panel float-panel">
{selected ? (
<AccountPanel accountId={selected.id} />
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-[11px] font-mono text-cyber-text-muted">
从左边选一个账号,或先扫码添加
</p>
</div>
)}
</div>
</div>
<AddAccountDialog open={addOpen} onOpenChange={setAddOpen} />