feat(creator): 新增「运营」模块 —— 多账号扫码登录与创作者后台数据
侧边栏在「监控」右边加了「运营」:账号列表 → 点进二级详情看该账号的数据。 【为什么是独立模块而不是监控的子视图】两者形状不同:监控是公开数据(点赞/收藏/评论/分享)的每轮快照+差分;运营是创作者后台按日期给出的曝光/观看/完播率/涨粉。凭据不同、采集方式也不同 —— 那边要浏览器登录态,这边是纯请求。硬塞进同一个模型会同时污染两边。 【扫码登录的关键差异】监控的扫码把登录态写进浏览器默认 profile(爬虫要复用)。运营要的是 cookie 字符串(纯请求够用),所以每次登录开一个**临时上下文**,扫完取出 cookie 就丢弃 —— 登第二个账号不会把第一个顶掉,也不影响监控那个登录态,十个账号互不干扰。 【决策依据】tools/probe_creator_api.py 的 Phase 0 实测:签名可自造(XYW_:MD5 → base64 → AES-128-CBC,与 xhshow 内置实现常量逐字节一致);主站 cookie 即可认证创作者后台;接口与参数已与真实页面对齐。 后端: - api/creator/models.py: creator_account / creator_note_stat。**复用 MonitorBase**,这样 create_all 与上一轮改成元数据驱动的 _ensure_columns 会自动覆盖新表 - api/creator/signing.py: XYW_ 签名,带三条实测结论(url= 前缀、appId=ugc、401 与 406 的区别) - api/creator/client.py: 纯 httpx 客户端。字段名尚未亲眼验证过,所以写成多别名匹配;解析不出来存 None 而非 0 - api/creator/service.py: 账号 CRUD 与同步。cookie 绝不进入对外结构,只给 has_cookie - api/creator/login.py: 临时上下文的扫码登录 - api/routers/creator.py: 8 条路由,全部带鉴权 前端: - 侧边栏「运营」+ OperationView(账号列表 → 二级详情)+ AddAccountDialog - 权限状态显眼呈现:pending 时照抄后台原话「已为您申请数据权限,次日可查看」,并说明此时同步返回 0 条是正常的,不是采集失败 测试:tests/test_creator_client.py 新增 48 例,含「cookie 不得出现在对外结构里」这条不变量,以及权限未生效时空壳响应的处理。
This commit is contained in:
+3
-1
@@ -5,13 +5,14 @@ import { Sidebar } from '@/components/layout/Sidebar'
|
||||
import { MainContent } from '@/components/layout/MainContent'
|
||||
import { CrawlerConfigPanel } from '@/components/config/CrawlerConfigPanel'
|
||||
import { MonitorDashboard } from '@/components/monitor/MonitorDashboard'
|
||||
import { OperationView } from '@/components/creator/OperationView'
|
||||
import { ReportView } from '@/components/monitor/ReportView'
|
||||
import { SettingsView } from '@/components/settings/SettingsView'
|
||||
import { Login } from '@/components/auth/Login'
|
||||
import { EnvironmentCheck, isEnvChecked } from '@/components/env/EnvironmentCheck'
|
||||
import { authApi, setUnauthorizedHandler } from '@/lib/api'
|
||||
|
||||
export type AppView = 'crawler' | 'monitor' | 'report' | 'settings'
|
||||
export type AppView = 'crawler' | 'monitor' | 'operation' | 'report' | 'settings'
|
||||
|
||||
function App() {
|
||||
// null = still probing. Rendering the app while unknown would briefly mount
|
||||
@@ -91,6 +92,7 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
{view === 'monitor' && <MonitorDashboard />}
|
||||
{view === 'operation' && <OperationView />}
|
||||
{view === 'report' && <ReportView />}
|
||||
{view === 'settings' && <SettingsView onNavigate={setView} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, CheckCircle2, Loader2, QrCode, RefreshCw, X } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
useCancelCreatorLogin,
|
||||
useCreatorLoginStatus,
|
||||
useStartCreatorLogin,
|
||||
} from '@/hooks/useCreator'
|
||||
|
||||
/**
|
||||
* 扫码新增一个运营账号。
|
||||
*
|
||||
* 后端给每个账号开一个**临时浏览器上下文**,扫完取出 cookie 就丢弃,所以:
|
||||
* 登第二个账号不会把第一个顶掉,也不会影响监控那个登录态。
|
||||
*/
|
||||
export function AddAccountDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [polling, setPolling] = useState(false)
|
||||
|
||||
const { data: state } = useCreatorLoginStatus(polling)
|
||||
const start = useStartCreatorLogin()
|
||||
const cancel = useCancelCreatorLogin()
|
||||
|
||||
const status = state?.status ?? 'idle'
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (status === 'waiting' || status === 'idle') return
|
||||
setPolling(false)
|
||||
if (status === 'success') {
|
||||
queryClient.invalidateQueries({ queryKey: ['creatorAccounts'] })
|
||||
}
|
||||
}, [status, open, queryClient])
|
||||
|
||||
// 关掉弹窗时把后端那个临时上下文也收掉,别让它挂在浏览器里。
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
setPolling(false)
|
||||
if (status === 'waiting') cancel.mutate()
|
||||
}
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
const begin = () => {
|
||||
setPolling(true)
|
||||
start.mutate()
|
||||
}
|
||||
|
||||
const busy = start.isPending || cancel.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono">新增运营账号</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">
|
||||
用小红书 App 扫码登录要添加的账号。每个账号使用独立的临时会话,互不影响。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
{status === 'waiting' && state?.image && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{/* 二维码直接来自页面,是个 data: URL —— 不经过第三方,也不在服务器上落盘 */}
|
||||
<img
|
||||
src={state.image}
|
||||
alt="登录二维码"
|
||||
className="w-44 h-44 rounded-md border border-cyber-border-DEFAULT bg-white p-1"
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{status === 'waiting' && !state?.image && (
|
||||
<p className="flex items-center justify-center gap-2 py-6 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">
|
||||
需要先在「系统设置」里打开 接管已有 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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
UserRound,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
useCheckCreatorAccount,
|
||||
useCreatorAccount,
|
||||
useCreatorAccounts,
|
||||
useDeleteCreatorAccount,
|
||||
useSyncCreatorAccount,
|
||||
} from '@/hooks/useCreator'
|
||||
import { formatCount, formatDateTime, formatRelative } from '@/lib/monitorFormat'
|
||||
import type { CreatorNote, CreatorPermissionStatus } from '@/types/creator'
|
||||
|
||||
import { AddAccountDialog } from './AddAccountDialog'
|
||||
|
||||
const PERMISSION_LABEL: Record<CreatorPermissionStatus, { text: string; tone: string }> = {
|
||||
active: { text: '数据已开通', tone: 'text-cyber-neon-green' },
|
||||
// 实测中最常见的一种,必须和"没有权限"分开说 —— 否则用户以为采集坏了,
|
||||
// 其实只是在等次日生效。
|
||||
pending: { text: '权限待生效', tone: 'text-cyber-neon-orange' },
|
||||
missing: { text: '无数据权限', tone: 'text-cyber-neon-pink' },
|
||||
unknown: { text: '权限未知', tone: 'text-cyber-text-muted' },
|
||||
}
|
||||
|
||||
const SUMMARY_TILES: Array<{ key: keyof CreatorNote; label: string }> = [
|
||||
{ key: 'exposure', label: '曝光' },
|
||||
{ key: 'views', label: '观看' },
|
||||
{ key: 'likes', label: '点赞' },
|
||||
{ key: 'comments', label: '评论' },
|
||||
{ key: 'favorites', label: '收藏' },
|
||||
{ key: 'shares', label: '分享' },
|
||||
{ key: 'new_followers', label: '涨粉' },
|
||||
]
|
||||
|
||||
/** 比率字段用百分号展示;其余用紧凑计数。 */
|
||||
function formatRate(value: number | null): string {
|
||||
return value === null ? '—' : `${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatSeconds(value: number | null): string {
|
||||
if (value === null) return '—'
|
||||
if (value < 60) return `${Math.round(value)} 秒`
|
||||
return `${Math.floor(value / 60)} 分 ${Math.round(value % 60)} 秒`
|
||||
}
|
||||
|
||||
function AccountList({
|
||||
selectedId,
|
||||
onSelect,
|
||||
onAdd,
|
||||
}: {
|
||||
selectedId: number | null
|
||||
onSelect: (id: number) => void
|
||||
onAdd: () => 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>
|
||||
)
|
||||
}
|
||||
|
||||
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" />
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () => void }) {
|
||||
const { data, isLoading } = useCreatorAccount(accountId)
|
||||
const sync = useSyncCreatorAccount()
|
||||
const check = useCheckCreatorAccount()
|
||||
const remove = useDeleteCreatorAccount()
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <p className="py-10 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
|
||||
}
|
||||
|
||||
const { account, notes, summary } = data
|
||||
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>
|
||||
{account.avatar && (
|
||||
<img
|
||||
src={account.avatar}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="font-mono text-xs text-cyber-text-primary">
|
||||
{account.nickname || '未命名账号'}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-cyber-text-muted">
|
||||
小红书号 {account.red_id || '—'}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={check.isPending}
|
||||
onClick={() => check.mutate(account.id)}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 mr-1 ${check.isPending ? 'animate-spin' : ''}`} />
|
||||
检测
|
||||
</Button>
|
||||
<Button size="sm" disabled={sync.isPending} onClick={() => sync.mutate({ id: account.id })}>
|
||||
<RefreshCw className={`w-3 h-3 mr-1 ${sync.isPending ? 'animate-spin' : ''}`} />
|
||||
同步数据
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
remove.mutate(account.id)
|
||||
onBack()
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营:管理自己的小红书账号。
|
||||
*
|
||||
* 与「监控」并列而非其子视图。监控抓的是公开数据(点赞/收藏/评论/分享),这里是
|
||||
* 创作者后台的运营指标(曝光/观看/完播率/涨粉)。两者数据形状不同、凭据不同、
|
||||
* 采集方式也不同(那边要浏览器登录态,这边是纯请求)。
|
||||
*/
|
||||
export function OperationView() {
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
|
||||
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 && (
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
新增账号
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedId === null ? (
|
||||
<AccountList
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onAdd={() => setAddOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<AccountDetail accountId={selectedId} onBack={() => setSelectedId(null)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddAccountDialog open={addOpen} onOpenChange={setAddOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Bug, Wifi, BarChart3, Cog, LogOut, Radar, Settings, Terminal } from 'lucide-react'
|
||||
import { Briefcase, Bug, Wifi, BarChart3, Cog, LogOut, Radar, Settings, Terminal } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { SystemSettingsDialog } from '@/components/settings/SystemSettingsDialog'
|
||||
@@ -19,6 +19,7 @@ interface SidebarProps {
|
||||
const NAV_ITEMS: Array<{ value: AppView; label: string; icon: typeof Terminal }> = [
|
||||
{ value: 'crawler', label: '采集', icon: Terminal },
|
||||
{ value: 'monitor', label: '监控', icon: Radar },
|
||||
{ value: 'operation', label: '运营', icon: Briefcase },
|
||||
{ value: 'report', label: '报表', icon: BarChart3 },
|
||||
{ value: 'settings', label: '设置', icon: Settings },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { creatorApi } from '@/lib/api'
|
||||
|
||||
const ACCOUNTS_KEY = ['creatorAccounts']
|
||||
|
||||
/** 账号列表。同步在后台跑,所以列表本身也顺带轮询,好让状态自己刷新出来。 */
|
||||
export function useCreatorAccounts(polling = false) {
|
||||
return useQuery({
|
||||
queryKey: ACCOUNTS_KEY,
|
||||
queryFn: async () => (await creatorApi.listAccounts()).data.accounts,
|
||||
refetchInterval: polling ? 5000 : false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreatorAccount(id: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ['creatorAccount', id],
|
||||
queryFn: async () => (await creatorApi.getAccount(id as number)).data,
|
||||
enabled: id !== null,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteCreatorAccount() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => creatorApi.deleteAccount(id),
|
||||
onSuccess: () => {
|
||||
toast.success('账号已删除')
|
||||
queryClient.invalidateQueries({ queryKey: ACCOUNTS_KEY })
|
||||
},
|
||||
onError: (error: Error) => toast.error(`删除失败:${error.message}`),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCheckCreatorAccount() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => creatorApi.checkAccount(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ACCOUNTS_KEY })
|
||||
},
|
||||
onError: (error: Error) => toast.error(`检测失败:${error.message}`),
|
||||
})
|
||||
}
|
||||
|
||||
export function useSyncCreatorAccount() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, days }: { id: number; days?: number }) =>
|
||||
creatorApi.syncAccount(id, days),
|
||||
onSuccess: () => {
|
||||
// 后端是后台任务,立刻重新拉一次只会看到旧状态;给用户一句"已开始",
|
||||
// 列表的轮询会把结果带回来。
|
||||
toast.success('同步已开始,稍后自动刷新')
|
||||
queryClient.invalidateQueries({ queryKey: ACCOUNTS_KEY })
|
||||
},
|
||||
onError: (error: Error) => toast.error(`同步失败:${error.message}`),
|
||||
})
|
||||
}
|
||||
|
||||
// --- 扫码新增账号 ---------------------------------------------------------
|
||||
|
||||
/** 只在扫码中轮询;空闲时没必要一直问。 */
|
||||
export function useCreatorLoginStatus(polling: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['creatorLogin'],
|
||||
queryFn: async () => (await creatorApi.getLogin()).data,
|
||||
refetchInterval: polling ? 2000 : false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useStartCreatorLogin() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => creatorApi.startLogin(),
|
||||
onSuccess: (response) => {
|
||||
queryClient.setQueryData(['creatorLogin'], response.data)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
// 后端给的原因是这里唯一有价值的信息(连不上 Chrome、页面上没有二维码),
|
||||
// 而 axios 会把它压成 "Request failed with status code 502"。
|
||||
const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail
|
||||
toast.error(`获取二维码失败:${detail ?? error.message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCancelCreatorLogin() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => creatorApi.cancelLogin(),
|
||||
onSuccess: (response) => {
|
||||
queryClient.setQueryData(['creatorLogin'], response.data)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,12 @@ import type {
|
||||
TaskCreatePayload,
|
||||
WebhookStatus,
|
||||
} from '@/types/monitor'
|
||||
import type {
|
||||
CreatorAccount,
|
||||
CreatorAccountDetail,
|
||||
CreatorLoginState,
|
||||
CreatorSyncResult,
|
||||
} from '@/types/creator'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -274,4 +280,23 @@ export const monitorApi = {
|
||||
testWebhook: (url?: string) => api.post('/monitor/webhook/test', { url: url ?? null }),
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营模块。与 `monitorApi` 并列 —— 它管的是自己的账号,走的是纯请求的创作者后台,
|
||||
* 和公开数据监控不是一回事。
|
||||
*/
|
||||
export const creatorApi = {
|
||||
listAccounts: () => api.get<{ accounts: CreatorAccount[] }>('/creator/accounts'),
|
||||
getAccount: (id: number) => api.get<CreatorAccountDetail>(`/creator/accounts/${id}`),
|
||||
deleteAccount: (id: number) => api.delete(`/creator/accounts/${id}`),
|
||||
checkAccount: (id: number) => api.post<CreatorAccount>(`/creator/accounts/${id}/check`),
|
||||
// 同步在后端后台跑(分页 + 节流可能几分钟),这里只负责触发。
|
||||
syncAccount: (id: number, days = 90) =>
|
||||
api.post<CreatorSyncResult>(`/creator/accounts/${id}/sync`, null, { params: { days } }),
|
||||
|
||||
// 扫码新增账号。cookie 只在后端内存里流转,不会出现在这些响应里。
|
||||
startLogin: () => api.post<CreatorLoginState>('/creator/login'),
|
||||
getLogin: () => api.get<CreatorLoginState>('/creator/login'),
|
||||
cancelLogin: () => api.delete<CreatorLoginState>('/creator/login'),
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/** 运营模块:自己的小红书账号,以及创作者后台给的数据。 */
|
||||
|
||||
/**
|
||||
* 数据权限状态。
|
||||
*
|
||||
* `pending` 是实测中最常见的一种:后台原话是「已为您申请数据权限,次日可查看」。
|
||||
* 它必须和「没有权限」分开显示 —— 否则用户会以为采集坏了,其实只是在等审批。
|
||||
*/
|
||||
export type CreatorPermissionStatus = 'unknown' | 'pending' | 'active' | 'missing'
|
||||
|
||||
export type CreatorAccountStatus = 'ok' | 'expired' | 'error'
|
||||
|
||||
/** 注意:接口**不下发 cookie**,只给 `has_cookie`。 */
|
||||
export interface CreatorAccount {
|
||||
id: number
|
||||
nickname: string
|
||||
user_id: string
|
||||
red_id: string
|
||||
avatar: string
|
||||
status: CreatorAccountStatus
|
||||
permission_status: CreatorPermissionStatus
|
||||
/** 后台原话,照抄不改写。 */
|
||||
permission_tip: string
|
||||
last_checked_at: number | null
|
||||
last_synced_at: number | null
|
||||
last_error: string | null
|
||||
has_cookie: boolean
|
||||
created_at: number
|
||||
note_count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 一篇作品的运营数据。
|
||||
*
|
||||
* 除时间外全部可为 null:接口没给、或解析不出来,都存 null 而不是 0 ——
|
||||
* 0 是真实值,null 是"不知道",混在一起报表会说谎。
|
||||
*/
|
||||
export interface CreatorNote {
|
||||
note_id: string
|
||||
title: string
|
||||
publish_time: number | null
|
||||
exposure: number | null
|
||||
views: number | null
|
||||
likes: number | null
|
||||
comments: number | null
|
||||
favorites: number | null
|
||||
shares: number | null
|
||||
new_followers: number | null
|
||||
danmaku: number | null
|
||||
cover_ctr: number | null
|
||||
avg_watch_seconds: number | null
|
||||
two_second_exit_rate: number | null
|
||||
completion_rate: number | null
|
||||
captured_at: number
|
||||
}
|
||||
|
||||
export interface CreatorSummary {
|
||||
exposure: number
|
||||
views: number
|
||||
likes: number
|
||||
comments: number
|
||||
favorites: number
|
||||
shares: number
|
||||
new_followers: number
|
||||
}
|
||||
|
||||
export interface CreatorAccountDetail {
|
||||
account: CreatorAccount
|
||||
notes: CreatorNote[]
|
||||
summary: CreatorSummary
|
||||
}
|
||||
|
||||
export type CreatorLoginStatus = 'idle' | 'waiting' | 'success' | 'expired' | 'error'
|
||||
|
||||
export interface CreatorLoginState {
|
||||
status: CreatorLoginStatus
|
||||
message: string
|
||||
/** `data:image/...;base64,...`, empty unless status is `waiting`. */
|
||||
image: string
|
||||
elapsed: number
|
||||
expires_in: number
|
||||
/** 扫码成功后后端已落库的账号。 */
|
||||
account: CreatorAccount | null
|
||||
}
|
||||
|
||||
export interface CreatorSyncResult {
|
||||
message: string
|
||||
days: number
|
||||
}
|
||||
Reference in New Issue
Block a user