feat(monitor): 博主的粉丝数,以及给作品起备注
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

两件都是「作品栏里把这东西认出来」的延伸:

* **账号级指标**:作品列表只会说「这条涨了多少赞」,说不了「这个人整个
  账号的粉丝在涨还是在掉」。抖音的资料接口本来就有粉丝数/总获赞/作品数,
  每轮顺手记一条快照(`monitor_creator_stat`,粒度 = 任务×博主×轮次,
  和作品指标同形)。组头显示最近一条。

  快照在「一条作品都没采到」的早退**之前**落:作品列表被风控挡住的那一轮,
  正是「粉丝还在涨、但新作品没在发现」最该被看见的时刻。

* **作品备注**:博主备注回答「这个账号是谁」,这条回答「这条我要盯着」。
  一个博主底下常常只有一两件值得盯的作品,所以不能合并成一条。键取
  (platform, note_id),跨任务共用一份。

两边都守住同一条口径:**不知道就是 null,不写成 0** —— 0 在趋势图上是一条
砸到底的线,和「还没采到」是两回事。
This commit is contained in:
2026-10-10 18:09:03 +08:00
parent 0a88474c92
commit 20e672834c
15 changed files with 953 additions and 10 deletions
+91 -4
View File
@@ -2,7 +2,7 @@ import { Fragment, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight, ExternalLink, Pencil, Users } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { useMonitorNotes, useSetCreatorAlias } from '@/hooks/useMonitor'
import { useMonitorNotes, useSetCreatorAlias, useSetNoteAlias } from '@/hooks/useMonitor'
import {
formatCount,
formatDate,
@@ -83,6 +83,32 @@ function sumMetrics(notes: MonitorNote[]) {
// 多出来的那几列:展开箭头、作品、发布日期、首次发现、跳转链接;分组表头会跨掉整行。
const COLUMN_COUNT = METRIC_COLUMNS.length + 5
/**
* 博主的**账号级**指标:粉丝 / 总获赞 / 作品数。
*
* 作品列表给不了这个 —— 那几列说的是「这条作品涨了多少赞」,这里说的是「这个人整个
* 账号在涨还是在掉」。**一个都没采到时整块不画**:画成「粉丝 0」比不画糟得多,那是
* 一句假话。
*/
function CreatorStats({ note }: { note: MonitorNote }) {
const parts = [
note.creator_fans !== null && `粉丝 ${formatCount(note.creator_fans)}`,
note.creator_total_favorited !== null && `获赞 ${formatCount(note.creator_total_favorited)}`,
note.creator_works !== null && `${formatCount(note.creator_works)} 作品`,
].filter(Boolean) as string[]
if (parts.length === 0) return null
return (
<span
className="text-cyber-text-muted flex-shrink-0"
title={`账号指标采集于 ${formatDateTime(note.creator_stats_at)}`}
>
{parts.join(' · ')}
</span>
)
}
/**
* 作品表,**按博主分组**。
*
@@ -102,6 +128,11 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
// 否则它会把你刚放弃的内容存进去。
const aliasCancelled = useRef(false)
const setAlias = useSetCreatorAlias()
// 正在改备注的那条**作品**。和博主备注是两个互不干扰的编辑态。
const [editingNote, setEditingNote] = useState<string | null>(null)
const [noteAliasDraft, setNoteAliasDraft] = useState('')
const noteAliasCancelled = useRef(false)
const setNoteAlias = useSetNoteAlias()
const commitAlias = (creatorHash: string) => {
if (aliasCancelled.current) {
@@ -113,6 +144,16 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
setAlias.mutate({ creatorHash, alias: aliasDraft })
}
const commitNoteAlias = (noteId: string) => {
if (noteAliasCancelled.current) {
noteAliasCancelled.current = false
setEditingNote(null)
return
}
setEditingNote(null)
setNoteAlias.mutate({ noteId, alias: noteAliasDraft })
}
const groups = useMemo(() => {
const byCreator = new Map<
string,
@@ -227,6 +268,8 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
<span className="text-cyber-text-muted flex-shrink-0">
{group.notes.length} 篇
</span>
{/* 账号级指标对组里每条作品都一样,取第一条即可。 */}
<CreatorStats note={group.notes[0]} />
<button
onClick={(event) => {
event.stopPropagation()
@@ -278,6 +321,16 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
NEW
</Badge>
)}
{/* 备注是**加**在标题前面的一枚标记,不是替换 ——
标题才是这条作品本身,扫列表时两个都要看得见。 */}
{note.note_alias && (
<Badge
variant="default"
className="text-[9px] px-1 py-0 flex-shrink-0"
>
{note.note_alias}
</Badge>
)}
<span
className="truncate text-cyber-text-primary"
title={note.title}
@@ -285,9 +338,43 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
{note.title || note.note_id}
</span>
</div>
<div className="text-[9px] text-cyber-text-muted truncate">
{note.note_id} · {note.snapshot_count} 次快照
</div>
{editingNote === rowKey ? (
<input
autoFocus
value={noteAliasDraft}
placeholder="备注,比如「重点跟拍」"
onClick={(event) => event.stopPropagation()}
onChange={(event) => setNoteAliasDraft(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === 'Enter') commitNoteAlias(note.note_id)
if (event.key === 'Escape') {
noteAliasCancelled.current = true
setEditingNote(null)
}
}}
onBlur={() => commitNoteAlias(note.note_id)}
className="mt-1 w-full rounded border border-cyber-neon-cyan/40 bg-cyber-bg-tertiary px-1.5 py-0.5 text-[11px] font-mono text-cyber-text-primary outline-none"
/>
) : (
<div className="flex items-center gap-1.5 text-[9px] text-cyber-text-muted">
<span className="truncate">
{note.note_id} · {note.snapshot_count} 次快照
</span>
<button
onClick={(event) => {
event.stopPropagation()
setNoteAliasDraft(note.note_alias)
setEditingNote(rowKey)
}}
title="给这条作品起个备注"
className="flex items-center gap-0.5 flex-shrink-0 text-cyber-text-muted hover:text-cyber-neon-cyan"
>
<Pencil className="w-2.5 h-2.5" />
{note.note_alias ? '改备注' : '备注'}
</button>
</div>
)}
</div>
</div>
</td>
+17
View File
@@ -370,6 +370,23 @@ export function useSetCreatorAlias() {
})
}
// --- 作品备注 --------------------------------------------------------------
export function useSetNoteAlias() {
const queryClient = useQueryClient()
const platform = usePlatformParam()
return useMutation({
mutationFn: ({ noteId, alias }: { noteId: string; alias: string }) =>
monitorApi.setNoteAlias(noteId, alias, platform),
onSuccess: () => {
toast.success('备注已保存')
queryClient.invalidateQueries({ queryKey: ['monitorNotes'] })
queryClient.invalidateQueries({ queryKey: ['monitorCommentsGrouped'] })
},
onError: (error: Error) => toast.error(`备注保存失败:${error.message}`),
})
}
// --- 上游更新检查 -----------------------------------------------------------------------------------------------------------------
export function useUpstreamStatus() {
+7
View File
@@ -290,6 +290,13 @@ export const monitorApi = {
{ alias },
{ params: { platform } },
),
/** 给作品起备注(空串 = 清掉)。键是 note_id —— 和博主备注是两回事。 */
setNoteAlias: (noteId: string, alias: string, platform?: string) =>
api.put(
`/monitor/notes/${encodeURIComponent(noteId)}`,
{ alias },
{ params: { platform } },
),
/**
* 立刻检查一次。服务端要等 fetch 跑完才回答,而 axios 默认 30 秒对此不够 ——
* 一条卡住的 git fetch 能拖到两分钟,这里必须单独放长超时,否则会误报失败。
+19
View File
@@ -102,6 +102,25 @@ export interface MonitorNote {
* 更认不出。备注是唯一能把账号对上人的东西。空串表示没起过。
*/
creator_alias: string
/**
* 人给**这条作品**起的备注。
*
* 和 `creator_alias` 是两件事:那条回答「这个账号是谁」,这条回答「这条我要盯着」。
* 一个博主底下常常只有一两件值得盯的作品,所以不能合并成一条。空串表示没起过。
*/
note_alias: string
/**
* 博主**账号级**指标 —— 作品列表给不了的东西:作品说的是「这条涨了多少赞」,
* 它说的是「这个人整个账号在涨还是在掉」。
*
* 三者都可能为 null:平台没采到(小红书那条路根本不产生它),或者某一项平台没给。
* 为 null 时界面整块不画 —— 画成「粉丝 0」就是在撒谎。
*/
creator_fans: number | null
creator_total_favorited: number | null
creator_works: number | null
/** 上述指标是哪一轮采到的。null = 从来没有过。 */
creator_stats_at: number | null
/** 作品的**发布**时间(爬虫侧:小红书 time、抖音 create_time)。可能与
* `first_seen_at` 差很远 —— 后者是「我们第一次看到它」的时间。平台没给时为 null。 */
published_at: number | null