fix(monitor): 没有作品的博主在作品栏里也要看得见
分组是从作品推出来的(按作品的 creator_hash 归组),于是没有作品的博主根本 不进列表:目标加了、资料采到了、粉丝数就躺在库里,界面上什么都看不见。 而这类博主恰恰是最该看见的 —— 还在涨粉,只是最近没发东西。藏起来正好藏反了。 线上就有一个:5 个目标里 3 个没作品,那 3 个连同已采到的粉丝数一起消失了。 改成 **账号快照 ∪ 作品** 两个来源: * 有快照没作品 → 一个 0 篇的组,备注和粉丝数照常显示,组里写「暂无作品」; * 有作品没快照 → 一个没有账号指标的组(小红书那条路不产生快照)。 /notes 因此多返回一份 `creators`,而不是让前端从作品里推 —— 作品推不出上面 第一类人。组头改读它,`MonitorNote` 上那几个字段降级成「顺着作品问作者」用。
This commit is contained in:
+116
-14
@@ -431,30 +431,31 @@ async def set_note_alias(
|
|||||||
|
|
||||||
async def _latest_creator_stats(
|
async def _latest_creator_stats(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
task_ids: Sequence[int],
|
task_ids: Optional[Sequence[int]] = None,
|
||||||
creator_hashes: Sequence[str],
|
creator_hashes: Optional[Sequence[str]] = None,
|
||||||
) -> Dict[tuple, "MonitorCreatorStat"]:
|
) -> Dict[tuple, "MonitorCreatorStat"]:
|
||||||
"""``(task_id, creator_hash) -> 最近一条``账号级快照。
|
"""``(task_id, creator_hash) -> 最近一条``账号级快照。
|
||||||
|
|
||||||
|
两个参数都是**可选过滤**:传 None 就是不限。``list_notes`` 两个都给(只要手里
|
||||||
|
这批作品涉及的博主),``list_creators`` 只给任务(要的是全部博主,包括一条作品
|
||||||
|
都没有的那些)。
|
||||||
|
|
||||||
按 run_id 而不是 captured_at 取「最近」:和作品指标用的是同一个口径,两者放一起
|
按 run_id 而不是 captured_at 取「最近」:和作品指标用的是同一个口径,两者放一起
|
||||||
看才不会出现「作品数据来自第 8 轮、粉丝数来自第 9 轮」这种对不上的情况。
|
看才不会出现「作品数据来自第 8 轮、粉丝数来自第 9 轮」这种对不上的情况。
|
||||||
"""
|
"""
|
||||||
if not task_ids or not creator_hashes:
|
if task_ids is not None and not task_ids:
|
||||||
|
return {}
|
||||||
|
if creator_hashes is not None and not creator_hashes:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
rows = (
|
query = select(MonitorCreatorStat).order_by(MonitorCreatorStat.run_id.desc())
|
||||||
await session.scalars(
|
if task_ids is not None:
|
||||||
select(MonitorCreatorStat)
|
query = query.where(MonitorCreatorStat.task_id.in_(list(task_ids)))
|
||||||
.where(
|
if creator_hashes is not None:
|
||||||
MonitorCreatorStat.task_id.in_(list(task_ids)),
|
query = query.where(MonitorCreatorStat.creator_hash.in_(list(creator_hashes)))
|
||||||
MonitorCreatorStat.creator_hash.in_(list(creator_hashes)),
|
|
||||||
)
|
|
||||||
.order_by(MonitorCreatorStat.run_id.desc())
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
latest: Dict[tuple, MonitorCreatorStat] = {}
|
latest: Dict[tuple, MonitorCreatorStat] = {}
|
||||||
for row in rows:
|
for row in (await session.scalars(query)).all():
|
||||||
latest.setdefault((row.task_id, row.creator_hash), row) # 已按 run_id 倒序
|
latest.setdefault((row.task_id, row.creator_hash), row) # 已按 run_id 倒序
|
||||||
return latest
|
return latest
|
||||||
|
|
||||||
@@ -604,6 +605,107 @@ async def list_notes(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def list_creators(
|
||||||
|
session: AsyncSession,
|
||||||
|
task_id: Optional[int] = None,
|
||||||
|
platform: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""作品栏里要显示的**博主** —— **包括一条作品都没有的**。
|
||||||
|
|
||||||
|
分组原先是从作品推出来的(按作品的 creator_hash 归组),于是没有作品的博主根本
|
||||||
|
不会出现在列表里:目标加了、资料也采到了、粉丝数就躺在库里,界面上什么都看不见。
|
||||||
|
而「这个号在涨粉、只是最近没发作品」恰恰是最该看见的一种情况 —— 藏起来正好藏反了。
|
||||||
|
|
||||||
|
所以来源换成 **账号快照 ∪ 作品**:
|
||||||
|
|
||||||
|
* 有快照没作品 → 一个 0 篇的组,粉丝数照常显示;
|
||||||
|
* 有作品没快照 → 一个没有账号指标的组(小红书那条路不产生快照,就是这种情况)。
|
||||||
|
|
||||||
|
``creator_alias`` 从作品备注那张表来;``note_count`` / ``last_activity_at`` 用来
|
||||||
|
排序,让最近还在动的博主排在前面。
|
||||||
|
"""
|
||||||
|
scope: Optional[List[int]] = None
|
||||||
|
if task_id is not None:
|
||||||
|
scope = [task_id]
|
||||||
|
elif platform is not None:
|
||||||
|
scope = await platform_task_ids(session, platform)
|
||||||
|
if not scope:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 作品一侧:谁有作品、有几篇、最后一次是什么时候。
|
||||||
|
work_query = (
|
||||||
|
select(
|
||||||
|
MonitorNote.task_id,
|
||||||
|
MonitorNote.creator_hash,
|
||||||
|
func.count().label("note_count"),
|
||||||
|
func.max(MonitorNote.last_seen_at).label("last_seen_at"),
|
||||||
|
func.max(MonitorNote.creator_name).label("creator_name"),
|
||||||
|
)
|
||||||
|
.where(MonitorNote.creator_hash != "")
|
||||||
|
.group_by(MonitorNote.task_id, MonitorNote.creator_hash)
|
||||||
|
)
|
||||||
|
if scope is not None:
|
||||||
|
work_query = work_query.where(MonitorNote.task_id.in_(scope))
|
||||||
|
|
||||||
|
work: Dict[tuple, Dict[str, Any]] = {}
|
||||||
|
for row in (await session.execute(work_query)).all():
|
||||||
|
work[(row.task_id, row.creator_hash)] = {
|
||||||
|
"note_count": row.note_count,
|
||||||
|
"last_seen_at": row.last_seen_at,
|
||||||
|
"creator_name": row.creator_name or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = await _latest_creator_stats(session, task_ids=scope)
|
||||||
|
if not work and not stats:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 备注是按 (platform, creator_hash) 存的,所以要知道每个博主属于哪个平台。
|
||||||
|
involved = {key[0] for key in set(work) | set(stats)}
|
||||||
|
task_platform = {
|
||||||
|
row.id: row.platform
|
||||||
|
for row in (
|
||||||
|
await session.execute(
|
||||||
|
select(MonitorTask.id, MonitorTask.platform).where(
|
||||||
|
MonitorTask.id.in_(list(involved))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
aliases = await _creator_alias_map(session)
|
||||||
|
|
||||||
|
result: List[Dict[str, Any]] = []
|
||||||
|
for key in set(work) | set(stats):
|
||||||
|
row_task, creator_hash = key
|
||||||
|
work_row = work.get(key)
|
||||||
|
stat = stats.get(key)
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"task_id": row_task,
|
||||||
|
"creator_hash": creator_hash,
|
||||||
|
# 昵称优先取作品的(那是界面上本来就在用的),快照的兜底 —— 没有作品
|
||||||
|
# 的博主只剩快照这一个来源。
|
||||||
|
"creator_name": (work_row or {}).get("creator_name")
|
||||||
|
or (stat.nickname if stat else ""),
|
||||||
|
"creator_alias": aliases.get(
|
||||||
|
(task_platform.get(row_task, ""), creator_hash), ""
|
||||||
|
),
|
||||||
|
"note_count": (work_row or {}).get("note_count", 0),
|
||||||
|
"creator_fans": stat.fans if stat else None,
|
||||||
|
"creator_total_favorited": stat.total_favorited if stat else None,
|
||||||
|
"creator_works": stat.works_count if stat else None,
|
||||||
|
"creator_stats_at": stat.captured_at if stat else None,
|
||||||
|
# 排序用:作品最近出现的时间,或者账号指标的采集时间,取晚的那个。
|
||||||
|
"last_activity_at": max(
|
||||||
|
(work_row or {}).get("last_seen_at") or 0,
|
||||||
|
stat.captured_at if stat else 0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result.sort(key=lambda row: row["last_activity_at"], reverse=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def note_series(session: AsyncSession, note_id: str, task_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
async def note_series(session: AsyncSession, note_id: str, task_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||||
"""Metric time series for one note."""
|
"""Metric time series for one note."""
|
||||||
query = (
|
query = (
|
||||||
|
|||||||
@@ -133,7 +133,10 @@ async def list_notes(
|
|||||||
):
|
):
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
return {
|
return {
|
||||||
"notes": await service.list_notes(session, task_id, only_new, limit, platform)
|
"notes": await service.list_notes(session, task_id, only_new, limit, platform),
|
||||||
|
# 博主**单独给一份**,而不是让前端从作品里推。作品推不出「一条作品都没有的
|
||||||
|
# 博主」—— 那正是最该显示的一类(还在涨粉,只是最近没发)。
|
||||||
|
"creators": await service.list_creators(session, task_id, platform),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -218,3 +218,105 @@ class TestCreatorStats:
|
|||||||
assert note["creator_works"] is None
|
assert note["creator_works"] is None
|
||||||
# 快照本身是有的(有采集时间),只是值不知道 —— 前端要能分开这两件事。
|
# 快照本身是有的(有采集时间),只是值不知道 —— 前端要能分开这两件事。
|
||||||
assert note["creator_stats_at"] is not None
|
assert note["creator_stats_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_task(platform: str = "xhs", task_name: str = "空任务") -> int:
|
||||||
|
"""只有任务,**一条作品都没有**。"""
|
||||||
|
async with monitor_db.get_session() as session:
|
||||||
|
task = MonitorTask(
|
||||||
|
name=task_name, platform=platform, mode=MODE_CREATOR, enabled=True,
|
||||||
|
interval_minutes=60, max_notes_count=20, enable_comments=False,
|
||||||
|
max_comments_count=50, run_timeout_seconds=3600,
|
||||||
|
notify_enabled=False, created_at=0, updated_at=0,
|
||||||
|
)
|
||||||
|
session.add(task)
|
||||||
|
await session.flush()
|
||||||
|
return task.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _creators(client, platform: str = "xhs"):
|
||||||
|
return (await client.get("/api/monitor/notes", params={"platform": platform})).json()["creators"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreatorList:
|
||||||
|
"""作品栏要显示谁 —— **包括一条作品都没有的博主**。
|
||||||
|
|
||||||
|
组原先是从作品推出来的,于是「目标加了、资料采到了、粉丝数在库里,界面上什么都
|
||||||
|
没有」。而这类博主恰恰最该看见:还在涨粉,只是最近没发东西。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_creator_with_no_works_still_shows_up(self, client):
|
||||||
|
"""**这条就是这个改动的全部理由。**"""
|
||||||
|
task_id = await _seed_task()
|
||||||
|
await _add_stat(task_id, run_id=1, fans=12000)
|
||||||
|
|
||||||
|
creators = await _creators(client)
|
||||||
|
|
||||||
|
assert len(creators) == 1
|
||||||
|
assert creators[0]["creator_hash"] == CREATOR_HASH
|
||||||
|
assert creators[0]["creator_name"] == NICKNAME # 只剩快照这一个来源
|
||||||
|
assert creators[0]["note_count"] == 0
|
||||||
|
assert creators[0]["creator_fans"] == 12000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_creator_with_works_carries_their_count(self, client):
|
||||||
|
task_id = await _seed()
|
||||||
|
await _add_stat(task_id, run_id=1, fans=12000)
|
||||||
|
|
||||||
|
creators = await _creators(client)
|
||||||
|
|
||||||
|
assert len(creators) == 1
|
||||||
|
assert creators[0]["note_count"] == 1
|
||||||
|
assert creators[0]["creator_name"] == NICKNAME
|
||||||
|
assert creators[0]["creator_fans"] == 12000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_creator_with_neither_works_nor_a_snapshot_is_absent(self, client):
|
||||||
|
"""两个来源都没有 = 我们对他一无所知,不该凭空造一个组出来。"""
|
||||||
|
await _seed_task()
|
||||||
|
|
||||||
|
assert await _creators(client) == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_work_without_a_snapshot_still_lists_its_creator(self, client):
|
||||||
|
"""小红书那条路不产生账号快照 —— 那边只能靠作品认出人来。"""
|
||||||
|
await _seed()
|
||||||
|
|
||||||
|
creators = await _creators(client)
|
||||||
|
|
||||||
|
assert len(creators) == 1
|
||||||
|
assert creators[0]["note_count"] == 1
|
||||||
|
assert creators[0]["creator_fans"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_creator_remark_comes_along(self, client):
|
||||||
|
"""没有作品的博主也要能起备注 —— 否则「这是谁」在最需要的时候认不出来。"""
|
||||||
|
task_id = await _seed_task()
|
||||||
|
await _add_stat(task_id, run_id=1, fans=12000)
|
||||||
|
|
||||||
|
await _set_alias(client, "竞品A")
|
||||||
|
|
||||||
|
assert (await _creators(client))[0]["creator_alias"] == "竞品A"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_platforms_stay_apart(self, client):
|
||||||
|
dy_task = await _seed_task(platform="dy")
|
||||||
|
await _add_stat(dy_task, run_id=1, fans=999)
|
||||||
|
|
||||||
|
assert await _creators(client, "xhs") == []
|
||||||
|
assert (await _creators(client, "dy"))[0]["creator_fans"] == 999
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_same_creator_under_two_tasks_is_two_rows_the_ui_merges(self, client):
|
||||||
|
"""服务端按 任务×博主 给(快照就是那么存的),合并交给界面 —— 因为备注跨任务
|
||||||
|
是同一条,合并之后的组才是人眼里的「一个博主」。"""
|
||||||
|
first = await _seed(task_name="任务甲")
|
||||||
|
second = await _seed(task_name="任务乙")
|
||||||
|
await _add_stat(first, run_id=1, fans=12000)
|
||||||
|
|
||||||
|
creators = await _creators(client)
|
||||||
|
|
||||||
|
assert len(creators) == 2
|
||||||
|
assert {row["creator_hash"] for row in creators} == {CREATOR_HASH}
|
||||||
|
assert sum(row["note_count"] for row in creators) == 2
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
formatDelta,
|
formatDelta,
|
||||||
formatRelative,
|
formatRelative,
|
||||||
} from '@/lib/monitorFormat'
|
} from '@/lib/monitorFormat'
|
||||||
import type { MonitorNote, NoteMetrics } from '@/types/monitor'
|
import type { MonitorCreator, MonitorNote, NoteMetrics } from '@/types/monitor'
|
||||||
import { NoteCover } from './NoteCover'
|
import { NoteCover } from './NoteCover'
|
||||||
import { NoteTrendChart } from './NoteTrendChart'
|
import { NoteTrendChart } from './NoteTrendChart'
|
||||||
|
|
||||||
@@ -83,6 +83,17 @@ function sumMetrics(notes: MonitorNote[]) {
|
|||||||
// 多出来的那几列:展开箭头、作品、发布日期、首次发现、跳转链接;分组表头会跨掉整行。
|
// 多出来的那几列:展开箭头、作品、发布日期、首次发现、跳转链接;分组表头会跨掉整行。
|
||||||
const COLUMN_COUNT = METRIC_COLUMNS.length + 5
|
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」比不画糟得多,那是
|
* 账号在涨还是在掉」。**一个都没采到时整块不画**:画成「粉丝 0」比不画糟得多,那是
|
||||||
* 一句假话。
|
* 一句假话。
|
||||||
*/
|
*/
|
||||||
function CreatorStats({ note }: { note: MonitorNote }) {
|
function CreatorStats({ stats }: { stats: AccountStats }) {
|
||||||
const parts = [
|
const parts = [
|
||||||
note.creator_fans !== null && `粉丝 ${formatCount(note.creator_fans)}`,
|
stats.creator_fans !== null && `粉丝 ${formatCount(stats.creator_fans)}`,
|
||||||
note.creator_total_favorited !== null && `获赞 ${formatCount(note.creator_total_favorited)}`,
|
stats.creator_total_favorited !== null && `获赞 ${formatCount(stats.creator_total_favorited)}`,
|
||||||
note.creator_works !== null && `${formatCount(note.creator_works)} 作品`,
|
stats.creator_works !== null && `${formatCount(stats.creator_works)} 作品`,
|
||||||
].filter(Boolean) as string[]
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
if (parts.length === 0) return null
|
if (parts.length === 0) return null
|
||||||
@@ -102,7 +113,7 @@ function CreatorStats({ note }: { note: MonitorNote }) {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="text-cyber-text-muted flex-shrink-0"
|
className="text-cyber-text-muted flex-shrink-0"
|
||||||
title={`账号指标采集于 ${formatDateTime(note.creator_stats_at)}`}
|
title={`账号指标采集于 ${formatDateTime(stats.creator_stats_at)}`}
|
||||||
>
|
>
|
||||||
{parts.join(' · ')}
|
{parts.join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
@@ -117,7 +128,9 @@ function CreatorStats({ note }: { note: MonitorNote }) {
|
|||||||
* 显示名是已脱敏的昵称(张***三)。两者都认不出来时才退回"未知博主"。
|
* 显示名是已脱敏的昵称(张***三)。两者都认不出来时才退回"未知博主"。
|
||||||
*/
|
*/
|
||||||
export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
|
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 [expandedNote, setExpandedNote] = useState<string | null>(null)
|
||||||
// 折叠状态按博主记。默认全展开 —— 藏起来的数据比多滚两屏更糟。
|
// 折叠状态按博主记。默认全展开 —— 藏起来的数据比多滚两屏更糟。
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||||||
@@ -155,32 +168,74 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const byCreator = new Map<
|
type Group = {
|
||||||
string,
|
key: string
|
||||||
{ key: string; name: string; alias: string; notes: MonitorNote[] }
|
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 ?? []) {
|
for (const note of notes ?? []) {
|
||||||
// 作品模式下每条作品都会带 creator_hash;真丢了也要有个兜底分组,
|
// 作品模式下每条作品都会带 creator_hash;真丢了也要有个兜底分组,
|
||||||
// 否则那些作品会凭空消失。
|
// 否则那些作品会凭空消失。
|
||||||
const key = note.creator_hash || '__unknown__'
|
const key = note.creator_hash || '__unknown__'
|
||||||
if (!byCreator.has(key)) {
|
let group = byCreator.get(key)
|
||||||
byCreator.set(key, {
|
if (!group) {
|
||||||
|
group = {
|
||||||
key,
|
key,
|
||||||
name: note.creator_name || '',
|
name: note.creator_name || '',
|
||||||
alias: note.creator_alias || '',
|
alias: note.creator_alias || '',
|
||||||
|
totalNotes: 0,
|
||||||
|
stats: null,
|
||||||
notes: [],
|
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()]
|
return [...byCreator.values()]
|
||||||
}, [notes])
|
}, [notes, creators])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
|
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 (
|
return (
|
||||||
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
|
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
|
||||||
{taskId === null
|
{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 truncate">{group.name}</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-cyber-text-muted flex-shrink-0">
|
<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>
|
</span>
|
||||||
{/* 账号级指标是按 (任务, 博主) 存的,而分组只按博主 —— 同一个博主
|
{group.stats && <CreatorStats stats={group.stats} />}
|
||||||
被两个任务监控时,组里可能只有一部分作品带指标。优先挑带指标的那条,
|
{/* 博主在、作品一条都没有 —— 说清楚,别让人以为列表挂了。 */}
|
||||||
否则「第一个任务还没采过」会让整组看起来没有指标。 */}
|
{group.notes.length === 0 && (
|
||||||
<CreatorStats
|
<span className="text-cyber-text-muted/70 flex-shrink-0">
|
||||||
note={
|
暂无作品
|
||||||
group.notes.find((note) => note.creator_stats_at !== null) ??
|
</span>
|
||||||
group.notes[0]
|
)}
|
||||||
}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|||||||
@@ -97,12 +97,19 @@ export function useTaskRuns(taskId: number | null) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作品**和博主**一起取。
|
||||||
|
*
|
||||||
|
* 博主不能从作品推出来 —— 「一条作品都没有的博主」在作品表里根本不存在,而它恰恰是
|
||||||
|
* 最该显示的一类。所以服务端单独给一份 `creators`,两者必须来自同一次请求,否则
|
||||||
|
* 组头和组里的作品会对不上(比如刚删掉一个任务)。
|
||||||
|
*/
|
||||||
export function useMonitorNotes(taskId: number | null, onlyNew: boolean) {
|
export function useMonitorNotes(taskId: number | null, onlyNew: boolean) {
|
||||||
const platform = usePlatformParam()
|
const platform = usePlatformParam()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['monitorNotes', platform, taskId, onlyNew],
|
queryKey: ['monitorNotes', platform, taskId, onlyNew],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await monitorApi.getNotes(taskId ?? undefined, onlyNew, 200, platform)).data.notes,
|
(await monitorApi.getNotes(taskId ?? undefined, onlyNew, 200, platform)).data,
|
||||||
refetchInterval: POLL_MS,
|
refetchInterval: POLL_MS,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CookieStatus,
|
CookieStatus,
|
||||||
MetricPoint,
|
MetricPoint,
|
||||||
MonitorComment,
|
MonitorComment,
|
||||||
|
MonitorCreator,
|
||||||
MonitorEvent,
|
MonitorEvent,
|
||||||
MonitorNote,
|
MonitorNote,
|
||||||
MonitorOverview,
|
MonitorOverview,
|
||||||
@@ -186,7 +187,7 @@ export const monitorApi = {
|
|||||||
api.get<{ runs: MonitorRun[] }>(`/monitor/tasks/${id}/runs`, { params: { limit } }),
|
api.get<{ runs: MonitorRun[] }>(`/monitor/tasks/${id}/runs`, { params: { limit } }),
|
||||||
|
|
||||||
getNotes: (taskId?: number, onlyNew = false, limit = 200, platform?: string) =>
|
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 },
|
params: { task_id: taskId, only_new: onlyNew, limit, platform },
|
||||||
}),
|
}),
|
||||||
// Metric time series for one note; the chart reads this.
|
// Metric time series for one note; the chart reads this.
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ export interface MonitorNote {
|
|||||||
*
|
*
|
||||||
* 三者都可能为 null:平台没采到(小红书那条路根本不产生它),或者某一项平台没给。
|
* 三者都可能为 null:平台没采到(小红书那条路根本不产生它),或者某一项平台没给。
|
||||||
* 为 null 时界面整块不画 —— 画成「粉丝 0」就是在撒谎。
|
* 为 null 时界面整块不画 —— 画成「粉丝 0」就是在撒谎。
|
||||||
|
*
|
||||||
|
* 作品栏的组头**不要读这里**,读 `MonitorCreator` —— 一条作品都没有的博主不会出现
|
||||||
|
* 在作品列表里,只有那份博主列表能覆盖他。这里是给「顺着一条作品问它的作者」用的。
|
||||||
*/
|
*/
|
||||||
creator_fans: number | null
|
creator_fans: number | null
|
||||||
creator_total_favorited: number | null
|
creator_total_favorited: number | null
|
||||||
@@ -133,6 +136,30 @@ export interface MonitorNote {
|
|||||||
snapshot_count: number
|
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 {
|
export interface MetricPoint {
|
||||||
run_id: number
|
run_id: number
|
||||||
captured_at: number
|
captured_at: number
|
||||||
|
|||||||
Reference in New Issue
Block a user