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
+60 -1
View File
@@ -40,6 +40,8 @@ from ..schemas.monitor import (
CookiePayload,
CreatorAliasPayload,
NoteAliasPayload,
NoteTagsPayload,
TagPayload,
MonitorTaskCreate,
MonitorTaskUpdate,
WebhookPayload,
@@ -130,13 +132,16 @@ async def list_notes(
only_new: bool = False,
limit: int = Query(default=200, ge=1, le=2000),
platform: Optional[str] = None,
tag_id: Optional[List[int]] = Query(
default=None, description="可重复传,命中其中任意一个标签即算"
),
):
async with get_session() as session:
return {
# windowed=True:作品栏是「正在盯的窗口」,每个博主只画最新 max_notes_count 条。
# 库里的全量还在,趋势图/报表/导出读的都是它。
"notes": await service.list_notes(
session, task_id, only_new, limit, platform, windowed=True
session, task_id, only_new, limit, platform, windowed=True, tag_ids=tag_id
),
# 博主**单独给一份**,而不是让前端从作品里推。作品推不出「一条作品都没有的
# 博主」—— 那正是最该显示的一类(还在涨粉,只是最近没发)。
@@ -299,6 +304,22 @@ async def set_note_alias_endpoint(
return {"note_id": note_id, "alias": payload.alias.strip()}
@router.put("/notes/{note_id}/tags")
async def set_note_tags_endpoint(
note_id: str,
payload: NoteTagsPayload,
platform: str = Query(default=PLATFORM_XHS),
):
"""把一条作品的标签**整体替换**成这一组(空列表 = 全摘掉)。
传的是「这条作品现在该有哪些标签」,而不是「加上/去掉哪一个」—— 界面上是一个
复选框对话框,点保存就是一次全量提交。
"""
async with get_session() as session:
tags = await service.set_note_tags(session, platform, note_id, payload.tag_ids)
return {"note_id": note_id, "tags": tags}
# ---------------------------------------------------------------------------
# QR login
# ---------------------------------------------------------------------------
@@ -416,6 +437,44 @@ async def _resolve_scope(
return [task for task in task_ids if task in platform_ids]
@router.get("/tags")
async def list_tags():
"""标签词表 + 各自被用在多少条作品上(删之前能看清代价)。"""
async with get_session() as session:
return {"tags": await service.list_tags(session)}
@router.post("/tags")
async def create_tag(payload: TagPayload):
async with get_session() as session:
try:
tag = await service.create_tag(session, payload.name, payload.color)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"id": tag.id, "name": tag.name, "color": tag.color, "note_count": 0}
@router.put("/tags/{tag_id}")
async def update_tag(tag_id: int, payload: TagPayload):
async with get_session() as session:
try:
tag = await service.update_tag(session, tag_id, payload.name, payload.color)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"id": tag.id, "name": tag.name, "color": tag.color}
@router.delete("/tags/{tag_id}")
async def delete_tag(tag_id: int):
"""删标签,连同它在所有作品上的贴纸。"""
async with get_session() as session:
try:
removed = await service.delete_tag(session, tag_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return {"removed": removed}
@router.get("/export")
async def export_data(
kind: str = Query(..., description="notes | comments | report"),