fix(monitor): 没有作品的博主在作品栏里也要看得见
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

分组是从作品推出来的(按作品的 creator_hash 归组),于是没有作品的博主根本
不进列表:目标加了、资料采到了、粉丝数就躺在库里,界面上什么都看不见。

而这类博主恰恰是最该看见的 —— 还在涨粉,只是最近没发东西。藏起来正好藏反了。
线上就有一个:5 个目标里 3 个没作品,那 3 个连同已采到的粉丝数一起消失了。

改成 **账号快照 ∪ 作品** 两个来源:

* 有快照没作品 → 一个 0 篇的组,备注和粉丝数照常显示,组里写「暂无作品」;
* 有作品没快照 → 一个没有账号指标的组(小红书那条路不产生快照)。

/notes 因此多返回一份 `creators`,而不是让前端从作品里推 —— 作品推不出上面
第一类人。组头改读它,`MonitorNote` 上那几个字段降级成「顺着作品问作者」用。
This commit is contained in:
2026-10-10 20:57:07 +08:00
parent 3c4daae0ac
commit e0581682e1
7 changed files with 342 additions and 44 deletions
+83 -27
View File
@@ -10,7 +10,7 @@ import {
formatDelta,
formatRelative,
} from '@/lib/monitorFormat'
import type { MonitorNote, NoteMetrics } from '@/types/monitor'
import type { MonitorCreator, MonitorNote, NoteMetrics } from '@/types/monitor'
import { NoteCover } from './NoteCover'
import { NoteTrendChart } from './NoteTrendChart'
@@ -83,6 +83,17 @@ function sumMetrics(notes: MonitorNote[]) {
// 多出来的那几列:展开箭头、作品、发布日期、首次发现、跳转链接;分组表头会跨掉整行。
const COLUMN_COUNT = METRIC_COLUMNS.length + 5
/**
* 账号级指标那几个字段。
*
* 作品的 payload 和博主的 payload 都带这一组,值也一样(同一次采集写的同一行),
* 所以组件只认字段、不认来源。
*/
type AccountStats = Pick<
MonitorCreator,
'creator_fans' | 'creator_total_favorited' | 'creator_works' | 'creator_stats_at'
>
/**
* 博主的**账号级**指标:粉丝 / 总获赞 / 作品数。
*
@@ -90,11 +101,11 @@ const COLUMN_COUNT = METRIC_COLUMNS.length + 5
* 账号在涨还是在掉」。**一个都没采到时整块不画**:画成「粉丝 0」比不画糟得多,那是
* 一句假话。
*/
function CreatorStats({ note }: { note: MonitorNote }) {
function CreatorStats({ stats }: { stats: AccountStats }) {
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)} 作品`,
stats.creator_fans !== null && `粉丝 ${formatCount(stats.creator_fans)}`,
stats.creator_total_favorited !== null && `获赞 ${formatCount(stats.creator_total_favorited)}`,
stats.creator_works !== null && `${formatCount(stats.creator_works)} 作品`,
].filter(Boolean) as string[]
if (parts.length === 0) return null
@@ -102,7 +113,7 @@ function CreatorStats({ note }: { note: MonitorNote }) {
return (
<span
className="text-cyber-text-muted flex-shrink-0"
title={`账号指标采集于 ${formatDateTime(note.creator_stats_at)}`}
title={`账号指标采集于 ${formatDateTime(stats.creator_stats_at)}`}
>
{parts.join(' · ')}
</span>
@@ -117,7 +128,9 @@ function CreatorStats({ note }: { note: MonitorNote }) {
* 显示名是已脱敏的昵称(张***三)。两者都认不出来时才退回"未知博主"。
*/
export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
const { data: notes, isLoading } = useMonitorNotes(taskId, onlyNew)
const { data, isLoading } = useMonitorNotes(taskId, onlyNew)
const notes = data?.notes
const creators = data?.creators
const [expandedNote, setExpandedNote] = useState<string | null>(null)
// 折叠状态按博主记。默认全展开 —— 藏起来的数据比多滚两屏更糟。
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
@@ -155,32 +168,74 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
}
const groups = useMemo(() => {
const byCreator = new Map<
string,
{ key: string; name: string; alias: string; notes: MonitorNote[] }
>()
type Group = {
key: string
name: string
alias: string
/** 服务端说的这条博主名下有多少作品 —— 列表被 `onlyNew` 滤过时和 `notes.length` 不等。 */
totalNotes: number
stats: MonitorCreator | null
notes: MonitorNote[]
}
const byCreator = new Map<string, Group>()
// **先放博主,不是先放作品。** 「一条作品都没有的博主」在作品里根本推不出来 ——
// 目标加了、资料也采到了、粉丝数就躺在库里,可界面上什么都看不见。而他恰恰是最该
// 看见的一个:还在涨粉,只是最近没发东西。
for (const creator of creators ?? []) {
const key = creator.creator_hash || '__unknown__'
const existing = byCreator.get(key)
if (existing) {
// 同一个博主可能挂在多个任务下(服务端按 任务×博主 给),合并成一组。
existing.totalNotes += creator.note_count
if (!existing.stats?.creator_stats_at && creator.creator_stats_at) {
existing.stats = creator
}
existing.name ||= creator.creator_name
// 备注按 (platform, creator_hash) 存,所以跨任务就是同一条,取到即可。
existing.alias ||= creator.creator_alias
continue
}
byCreator.set(key, {
key,
name: creator.creator_name || '',
alias: creator.creator_alias || '',
totalNotes: creator.note_count,
stats: creator,
notes: [],
})
}
for (const note of notes ?? []) {
// 作品模式下每条作品都会带 creator_hash;真丢了也要有个兜底分组,
// 否则那些作品会凭空消失。
const key = note.creator_hash || '__unknown__'
if (!byCreator.has(key)) {
byCreator.set(key, {
let group = byCreator.get(key)
if (!group) {
group = {
key,
name: note.creator_name || '',
alias: note.creator_alias || '',
totalNotes: 0,
stats: null,
notes: [],
})
}
byCreator.set(key, group)
}
byCreator.get(key)!.notes.push(note)
group.notes.push(note)
group.name ||= note.creator_name || ''
group.alias ||= note.creator_alias || ''
}
return [...byCreator.values()]
}, [notes])
}, [notes, creators])
if (isLoading) {
return <p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
}
if (!notes || notes.length === 0) {
// 博主有、作品没有也是**要画**的:那正是「这个号还没被删,只是没发东西」。
if (groups.length === 0) {
return (
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
{taskId === null
@@ -266,17 +321,18 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
<span className="text-cyber-text-muted truncate">{group.name}</span>
)}
<span className="text-cyber-text-muted flex-shrink-0">
{group.notes.length} 篇
{/* 被 onlyNew 滤过时,把「显示了几篇 / 一共几篇」都说出来,
否则「1 篇」会让人以为这个号只发过一条。 */}
{group.notes.length}
{group.totalNotes > group.notes.length && `/${group.totalNotes}`} 篇
</span>
{/* 账号级指标是按 (任务, 博主) 存的,而分组只按博主 —— 同一个博主
被两个任务监控时,组里可能只有一部分作品带指标。优先挑带指标的那条,
否则「第一个任务还没采过」会让整组看起来没有指标。 */}
<CreatorStats
note={
group.notes.find((note) => note.creator_stats_at !== null) ??
group.notes[0]
}
/>
{group.stats && <CreatorStats stats={group.stats} />}
{/* 博主在、作品一条都没有 —— 说清楚,别让人以为列表挂了。 */}
{group.notes.length === 0 && (
<span className="text-cyber-text-muted/70 flex-shrink-0">
暂无作品
</span>
)}
<button
onClick={(event) => {
event.stopPropagation()
+8 -1
View File
@@ -97,12 +97,19 @@ export function useTaskRuns(taskId: number | null) {
})
}
/**
* 作品**和博主**一起取。
*
* 博主不能从作品推出来 —— 「一条作品都没有的博主」在作品表里根本不存在,而它恰恰是
* 最该显示的一类。所以服务端单独给一份 `creators`,两者必须来自同一次请求,否则
* 组头和组里的作品会对不上(比如刚删掉一个任务)。
*/
export function useMonitorNotes(taskId: number | null, onlyNew: boolean) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorNotes', platform, taskId, onlyNew],
queryFn: async () =>
(await monitorApi.getNotes(taskId ?? undefined, onlyNew, 200, platform)).data.notes,
(await monitorApi.getNotes(taskId ?? undefined, onlyNew, 200, platform)).data,
refetchInterval: POLL_MS,
})
}
+2 -1
View File
@@ -5,6 +5,7 @@ import type {
CookieStatus,
MetricPoint,
MonitorComment,
MonitorCreator,
MonitorEvent,
MonitorNote,
MonitorOverview,
@@ -186,7 +187,7 @@ export const monitorApi = {
api.get<{ runs: MonitorRun[] }>(`/monitor/tasks/${id}/runs`, { params: { limit } }),
getNotes: (taskId?: number, onlyNew = false, limit = 200, platform?: string) =>
api.get<{ notes: MonitorNote[] }>('/monitor/notes', {
api.get<{ notes: MonitorNote[]; creators: MonitorCreator[] }>('/monitor/notes', {
params: { task_id: taskId, only_new: onlyNew, limit, platform },
}),
// Metric time series for one note; the chart reads this.
+27
View File
@@ -115,6 +115,9 @@ export interface MonitorNote {
*
* 三者都可能为 null:平台没采到(小红书那条路根本不产生它),或者某一项平台没给。
* 为 null 时界面整块不画 —— 画成「粉丝 0」就是在撒谎。
*
* 作品栏的组头**不要读这里**,读 `MonitorCreator` —— 一条作品都没有的博主不会出现
* 在作品列表里,只有那份博主列表能覆盖他。这里是给「顺着一条作品问它的作者」用的。
*/
creator_fans: number | null
creator_total_favorited: number | null
@@ -133,6 +136,30 @@ export interface MonitorNote {
snapshot_count: number
}
/**
* 作品栏里的一位博主 —— **不依赖于他有没有作品**。
*
* 作品表是按 creator_hash 从作品推出来的,所以「一条作品都没有的博主」在那边根本
* 不存在。可这类博主恰恰是最该看见的:还在涨粉,只是最近没发东西。服务端因此单独
* 给一份列表,来源是**账号快照 ∪ 作品**。
*
* 没有作品时 `note_count` 是 0,其余字段照常有;反过来,小红书那条路不产生账号
* 快照,于是 `creator_fans` 等为 null —— 两边各缺一块,界面要都能显示。
*/
export interface MonitorCreator {
task_id: number
creator_hash: string
creator_name: string
creator_alias: string
note_count: number
creator_fans: number | null
creator_total_favorited: number | null
creator_works: number | null
creator_stats_at: number | null
/** 作品最后出现、或账号指标最后采集的时间 —— 排序用。 */
last_activity_at: number
}
export interface MetricPoint {
run_id: number
captured_at: number