feat(monitor): 作品标签 —— 词表在设置里维护,作品上贴
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

和「作品备注」刻意并存,不是一回事:备注是一句话的自由文字,标签是**从一份固定
词表里选的分类**。分类必须封闭,否则「重点 / 重要 / 优先」各写各的,筛就没法用了。

* `monitor_tag`:词表,全局一套(不按平台分)—— 「重点」是给人自己用的心智,不该在
  小红书和抖音各定义一遍。颜色存的是**调色板里的名字**,不是色值:Tailwind 的类名是
  静态提取的,拼出来的 `text-${color}` 它看不见,线上会静默变无色。
* `monitor_note_tag`:作品↔标签,**多对多**。一条作品可以既是重点又是竞品;只能贴一个
  的话人就会跑去备注里写自由文字,这份词表就白建了。全部替换式提交,不是逐个增删。
* 删除标签时**显式删关联**,不靠外键级联 —— 测试跑 SQLite,它默认不开外键约束。
* 组头上的筛选做成平铺开关片,多选是「或」。

顺带:/monitor/notes 现在支持按 tag_id 筛。
This commit is contained in:
2026-10-11 09:16:17 +08:00
parent 5c7577cab0
commit b9aa590814
15 changed files with 1147 additions and 10 deletions
+196
View File
@@ -40,6 +40,8 @@ from .models import (
MonitorNote,
MonitorNoteAlias,
MonitorNoteMetric,
MonitorNoteTag,
MonitorTag,
MonitorRun,
MonitorTarget,
MonitorTask,
@@ -429,6 +431,184 @@ async def set_note_alias(
existing.updated_at = get_current_timestamp()
# 标签能用的颜色。**必须是写死的字面量** —— Tailwind 靠静态扫描找类名,
# `text-cyber-neon-${color}` 这种拼出来的类它看不见,线上会全变成无色。
TAG_COLORS: Dict[str, str] = {
"cyan": "border-cyber-neon-cyan/40 text-cyber-neon-cyan",
"pink": "border-cyber-neon-pink/40 text-cyber-neon-pink",
"green": "border-cyber-neon-green/40 text-cyber-neon-green",
"orange": "border-cyber-neon-orange/40 text-cyber-neon-orange",
"muted": "border-cyber-border-DEFAULT text-cyber-text-secondary",
}
DEFAULT_TAG_COLOR = "cyan"
async def list_tags(session: AsyncSession) -> List[Dict[str, Any]]:
"""标签词表,带上各自被用在多少条作品上。
带上计数是为了**删之前能看清代价** —— 「这个标签用在 47 条作品上」,和「0 条」,
点删除时是完全不同的两个决定。
"""
counts = dict(
(
await session.execute(
select(MonitorNoteTag.tag_id, func.count()).group_by(MonitorNoteTag.tag_id)
)
).all()
)
rows = (
await session.scalars(
select(MonitorTag).order_by(MonitorTag.sort_order, MonitorTag.id)
)
).all()
return [
{
"id": row.id,
"name": row.name,
"color": row.color or DEFAULT_TAG_COLOR,
"note_count": counts.get(row.id, 0),
}
for row in rows
]
async def create_tag(session: AsyncSession, name: str, color: str = "") -> MonitorTag:
"""新建标签。重名直接拒绝 —— 两个「重点」在筛选列表里是灾难。"""
name = (name or "").strip()[:32]
if not name:
raise ValueError("标签名不能为空")
existing = await session.scalar(select(MonitorTag).where(MonitorTag.name == name))
if existing is not None:
raise ValueError(f"标签「{name}」已经存在")
# 排在最后。用 max+1 而不是 count:删过标签之后 count 会撞上已有的 sort_order。
last = await session.scalar(select(func.max(MonitorTag.sort_order)))
tag = MonitorTag(
name=name,
color=color if color in TAG_COLORS else DEFAULT_TAG_COLOR,
sort_order=(last or 0) + 1,
created_at=get_current_timestamp(),
)
session.add(tag)
await session.flush()
return tag
async def update_tag(
session: AsyncSession, tag_id: int, name: str, color: str = ""
) -> MonitorTag:
tag = await session.get(MonitorTag, tag_id)
if tag is None:
raise ValueError(f"标签 {tag_id} 不存在")
name = (name or "").strip()[:32]
if not name:
raise ValueError("标签名不能为空")
clash = await session.scalar(
select(MonitorTag).where(MonitorTag.name == name, MonitorTag.id != tag_id)
)
if clash is not None:
raise ValueError(f"标签「{name}」已经存在")
tag.name = name
tag.color = color if color in TAG_COLORS else DEFAULT_TAG_COLOR
await session.flush()
return tag
async def delete_tag(session: AsyncSession, tag_id: int) -> int:
"""删标签,连同它在所有作品上的贴纸(返回清掉了多少条)。
**显式删关联,不靠外键级联**:测试跑的是 SQLite,而它默认不启用外键约束 ——
依赖级联的话,测试全绿、线上才对,或者反过来。
"""
tag = await session.get(MonitorTag, tag_id)
if tag is None:
raise ValueError(f"标签 {tag_id} 不存在")
removed = await session.execute(
delete(MonitorNoteTag).where(MonitorNoteTag.tag_id == tag_id)
)
await session.delete(tag)
return removed.rowcount or 0
async def _note_tag_map(session: AsyncSession) -> Dict[tuple, List[Dict[str, Any]]]:
"""``(platform, note_id) -> [标签]``。整体读一次,贴纸本来就不多。"""
rows = (
await session.execute(
select(MonitorNoteTag.platform, MonitorNoteTag.note_id, MonitorTag)
.join(MonitorTag, MonitorTag.id == MonitorNoteTag.tag_id)
.order_by(MonitorTag.sort_order, MonitorTag.id)
)
).all()
result: Dict[tuple, List[Dict[str, Any]]] = {}
for platform, note_id, tag in rows:
result.setdefault((platform, note_id), []).append(
{"id": tag.id, "name": tag.name, "color": tag.color or DEFAULT_TAG_COLOR}
)
return result
async def set_note_tags(
session: AsyncSession, platform: str, note_id: str, tag_ids: Sequence[int]
) -> List[Dict[str, Any]]:
"""把一条作品的标签**整体替换**成这一组。
整体替换而不是增删单条:界面给的是「这条作品现在的标签」,一个复选框对话框点完
保存就是一次全量提交,比对齐两边的差集简单也不容易错。
"""
wanted = {int(tag_id) for tag_id in tag_ids}
# 只认词表里真有的 id —— 前端传了脏 id 就静静忽略,别在关联表里留下孤儿。
known = set(
(await session.scalars(select(MonitorTag.id).where(MonitorTag.id.in_(wanted)))).all()
if wanted
else []
)
wanted &= known
existing = set(
(
await session.scalars(
select(MonitorNoteTag.tag_id).where(
MonitorNoteTag.platform == platform,
MonitorNoteTag.note_id == note_id,
)
)
).all()
)
for tag_id in existing - wanted:
await session.execute(
delete(MonitorNoteTag).where(
MonitorNoteTag.platform == platform,
MonitorNoteTag.note_id == note_id,
MonitorNoteTag.tag_id == tag_id,
)
)
now = get_current_timestamp()
for tag_id in wanted - existing:
session.add(
MonitorNoteTag(
platform=platform,
note_id=note_id,
tag_id=tag_id,
created_at=now,
)
)
await session.flush()
tags = await _note_tag_map(session)
return tags.get((platform, note_id), [])
async def _latest_creator_stats(
session: AsyncSession,
task_ids: Optional[Sequence[int]] = None,
@@ -517,6 +697,7 @@ async def list_notes(
platform: Optional[str] = None,
*,
windowed: bool = False,
tag_ids: Optional[Sequence[int]] = None,
) -> List[Dict[str, Any]]:
"""Tracked notes with their latest metrics and change vs the previous run.
@@ -537,6 +718,17 @@ async def list_notes(
return []
query = query.where(MonitorNote.task_id.in_(scoped))
if tag_ids:
# 命中**任意一个**选中的标签。作品 id 在平台内唯一,而这里的 query 本来就已经
# 被任务/平台圈定了,所以只按 note_id 关联就够。
query = query.where(
MonitorNote.note_id.in_(
select(MonitorNoteTag.note_id).where(
MonitorNoteTag.tag_id.in_([int(t) for t in tag_ids])
)
)
)
notes = list((await session.scalars(query)).all())
if not notes:
return []
@@ -581,6 +773,7 @@ async def list_notes(
}
aliases = await _creator_alias_map(session)
note_aliases = await _note_alias_map(session)
note_tags = await _note_tag_map(session)
# 账号级指标(粉丝 / 总获赞 / 作品数)。**挂在作品上一起返回**,因为界面上就是按博主
# 归组显示的 —— 让前端为了一个组头再发一轮请求没道理。同一个博主的所有作品拿到的是
# 同一条(键里带 task_id,所以跨任务不会串)。没有的(小红书那条路不产生它)就是 null,
@@ -629,6 +822,9 @@ async def list_notes(
"note_alias": note_aliases.get(
(task_platform.get(note.task_id, ""), note.note_id), ""
),
# 贴在这条作品上的标签。备注是自由文字(一句话说明),标签是从设置里那份
# 词表选的分类 —— 两者互补,不是一回事。
"tags": note_tags.get((task_platform.get(note.task_id, ""), note.note_id), []),
# 博主账号级指标 —— 作品列表给不了的东西。三个值都可能为 null(平台没采
# 到、或者这条作品来自不产生它的数据源),前端据此整块不画。
"creator_fans": stat.fans if stat else None,