feat(monitor): 作品标签 —— 词表在设置里维护,作品上贴
和「作品备注」刻意并存,不是一回事:备注是一句话的自由文字,标签是**从一份固定
词表里选的分类**。分类必须封闭,否则「重点 / 重要 / 优先」各写各的,筛就没法用了。
* `monitor_tag`:词表,全局一套(不按平台分)—— 「重点」是给人自己用的心智,不该在
小红书和抖音各定义一遍。颜色存的是**调色板里的名字**,不是色值:Tailwind 的类名是
静态提取的,拼出来的 `text-${color}` 它看不见,线上会静默变无色。
* `monitor_note_tag`:作品↔标签,**多对多**。一条作品可以既是重点又是竞品;只能贴一个
的话人就会跑去备注里写自由文字,这份词表就白建了。全部替换式提交,不是逐个增删。
* 删除标签时**显式删关联**,不靠外键级联 —— 测试跑 SQLite,它默认不开外键约束。
* 组头上的筛选做成平铺开关片,多选是「或」。
顺带:/monitor/notes 现在支持按 tag_id 筛。
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user