feat(monitor): 博主的粉丝数,以及给作品起备注
两件都是「作品栏里把这东西认出来」的延伸: * **账号级指标**:作品列表只会说「这条涨了多少赞」,说不了「这个人整个 账号的粉丝在涨还是在掉」。抖音的资料接口本来就有粉丝数/总获赞/作品数, 每轮顺手记一条快照(`monitor_creator_stat`,粒度 = 任务×博主×轮次, 和作品指标同形)。组头显示最近一条。 快照在「一条作品都没采到」的早退**之前**落:作品列表被风控挡住的那一轮, 正是「粉丝还在涨、但新作品没在发现」最该被看见的时刻。 * **作品备注**:博主备注回答「这个账号是谁」,这条回答「这条我要盯着」。 一个博主底下常常只有一两件值得盯的作品,所以不能合并成一条。键取 (platform, note_id),跨任务共用一份。 两边都守住同一条口径:**不知道就是 null,不写成 0** —— 0 在趋势图上是一条 砸到底的线,和「还没采到」是两回事。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 能拖到两分钟,这里必须单独放长超时,否则会误报失败。
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user