diff --git a/api/monitor/models.py b/api/monitor/models.py index 5ed6273..5285b25 100644 --- a/api/monitor/models.py +++ b/api/monitor/models.py @@ -405,6 +405,50 @@ class MonitorNoteAlias(MonitorBase): updated_at: Mapped[int] = mapped_column(BigInteger, nullable=False) +class MonitorTag(MonitorBase): + """作品标签的**词表** —— 在设置里维护,贴标签时从这里选。 + + 刻意做成一张表而不是一个设置项:设置里那个注册表存的是标量(一个数、一段文字), + 而这是一组能增能删能改名的条目。 + + **全局一套,不按平台分。** 「重点」「竞品」这种分类是给人自己用的心智,不该在小红书 + 和抖音各定义一遍;而且同一个词在两个平台上含义不同的话,反而不好比。 + """ + + __tablename__ = "monitor_tag" + __table_args__ = (UniqueConstraint("name", name="uq_tag_name"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(32), nullable=False) + # 一个**调色板里的名字**(cyan/pink/green/…),不是颜色值。Tailwind 的类名是静态 + # 提取的,拼不出 `text-${color}` 那种动态类,所以这里存的是查表用的键。 + color: Mapped[str] = mapped_column(String(16), nullable=False, default="") + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + + +class MonitorNoteTag(MonitorBase): + """作品 ↔ 标签。 + + 键取 ``(platform, note_id)``,和作品备注同一个口径:作品 id 本身带平台语义,但显式 + 带上 platform 才能和别处用同一套查询形状。 + + **多对多**:一条作品可以既是「重点」又是「竞品」—— 标签本来就是可以叠加的分类, + 一个不够用的话人就会去备注里写自由文字,那这个功能就白做了。 + """ + + __tablename__ = "monitor_note_tag" + __table_args__ = ( + UniqueConstraint("platform", "note_id", "tag_id", name="uq_note_tag"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + platform: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + note_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + tag_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + created_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + + class MonitorSetting(MonitorBase): """Key/value store. Holds the XHS cookie for unattended runs.""" diff --git a/api/monitor/service.py b/api/monitor/service.py index 58931cb..4905034 100644 --- a/api/monitor/service.py +++ b/api/monitor/service.py @@ -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, diff --git a/api/routers/monitor.py b/api/routers/monitor.py index d585afe..3d043a9 100644 --- a/api/routers/monitor.py +++ b/api/routers/monitor.py @@ -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"), diff --git a/api/schemas/monitor.py b/api/schemas/monitor.py index 622bdac..6eaa0b0 100644 --- a/api/schemas/monitor.py +++ b/api/schemas/monitor.py @@ -120,6 +120,19 @@ class NoteAliasPayload(BaseModel): alias: str = Field(default="", max_length=128) +class TagPayload(BaseModel): + """新建/改名一个标签。`color` 是调色板里的名字,不认识的会被回落到默认色。""" + + name: str = Field(min_length=1, max_length=32) + color: str = Field(default="", max_length=16) + + +class NoteTagsPayload(BaseModel): + """把一条作品的标签整体替换成这一组。空列表 = 全摘掉。""" + + tag_ids: List[int] = Field(default_factory=list) + + class WebhookPayload(BaseModel): url: str = Field(default="", description="企业微信机器人 Webhook 地址,留空表示停用") diff --git a/tests/test_monitor_tags.py b/tests/test_monitor_tags.py new file mode 100644 index 0000000..d85ddde --- /dev/null +++ b/tests/test_monitor_tags.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +"""作品标签 —— 词表在设置里维护,作品上贴。 + +和「作品备注」是两件事,刻意并存:备注是一句话说明(自由文字),标签是从**一份固定 +词表**里选的分类。分类得是一套封闭的词,否则「重点 / 重要 / 优先」会各写各的,筛选 +就没法用了。 +""" + +import httpx +import pytest +import pytest_asyncio + +from api.main import app +from api.monitor import db as monitor_db +from api.monitor.models import MODE_CREATOR, MonitorNote, MonitorTask + + +@pytest_asyncio.fixture +async def client(tmp_path): + monitor_db.set_sqlite_path(tmp_path / "monitor.db") + await monitor_db.init_db() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + await monitor_db.dispose_engine() + + +async def _seed(platform: str = "xhs", task_name: str = "任务", note_ids=("note-a",)) -> 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() + for note_id in note_ids: + session.add( + MonitorNote( + task_id=task.id, note_id=note_id, title=f"作品{note_id}", + note_url="", cover="", creator_hash="hash-a", + creator_name="博主", source_kind="", published_at=1, + first_seen_run_id=1, first_seen_at=1, + last_seen_run_id=1, last_seen_at=1, + ) + ) + return task.id + + +async def _make_tag(client, name: str, color: str = "") -> int: + response = await client.post("/api/monitor/tags", json={"name": name, "color": color}) + assert response.status_code == 200, response.text + return response.json()["id"] + + +async def _tags(client): + return (await client.get("/api/monitor/tags")).json()["tags"] + + +async def _notes(client, **params): + return (await client.get("/api/monitor/notes", params=params)).json()["notes"] + + +async def _attach(client, note_id: str, tag_ids, platform: str = "xhs"): + return await client.put( + f"/api/monitor/notes/{note_id}/tags", + params={"platform": platform}, + json={"tag_ids": tag_ids}, + ) + + +class TestTagVocabulary: + @pytest.mark.asyncio + async def test_a_new_tag_shows_up_in_the_list(self, client): + await _make_tag(client, "重点", "pink") + + tags = await _tags(client) + + assert [(tag["name"], tag["color"]) for tag in tags] == [("重点", "pink")] + + @pytest.mark.asyncio + async def test_duplicate_names_are_rejected(self, client): + """两个「重点」在筛选列表里是灾难 —— 选哪个都像对的。""" + await _make_tag(client, "重点") + + response = await client.post("/api/monitor/tags", json={"name": "重点"}) + + assert response.status_code == 400 + assert "已经存在" in response.json()["detail"] + + @pytest.mark.asyncio + async def test_an_unknown_color_falls_back_instead_of_breaking(self, client): + await _make_tag(client, "重点", color="chartreuse") + + assert (await _tags(client))[0]["color"] == "cyan" + + @pytest.mark.asyncio + async def test_renaming_works(self, client): + tag_id = await _make_tag(client, "重点") + + await client.put(f"/api/monitor/tags/{tag_id}", json={"name": "非常重点"}) + + assert (await _tags(client))[0]["name"] == "非常重点" + + @pytest.mark.asyncio + async def test_renaming_onto_an_existing_name_is_rejected(self, client): + await _make_tag(client, "重点") + tag_id = await _make_tag(client, "次要") + + response = await client.put( + f"/api/monitor/tags/{tag_id}", json={"name": "重点"} + ) + + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_the_count_says_how_many_works_wear_it(self, client): + """删之前要能看清代价:用在 47 条作品上和 0 条,是两个决定。""" + await _seed(note_ids=("note-a", "note-b")) + tag_id = await _make_tag(client, "重点") + + await _attach(client, "note-a", [tag_id]) + await _attach(client, "note-b", [tag_id]) + + assert (await _tags(client))[0]["note_count"] == 2 + + +class TestTaggingWorks: + @pytest.mark.asyncio + async def test_a_work_can_wear_several_tags(self, client): + """**多对多是这个功能的重点**:一条作品可以既是「重点」又是「竞品」。只能贴一个 + 的话,人就会跑去备注里写自由文字,那这份封闭词表就白建了。""" + await _seed() + first = await _make_tag(client, "重点") + second = await _make_tag(client, "竞品") + + await _attach(client, "note-a", [first, second]) + + names = [tag["name"] for tag in (await _notes(client))[0]["tags"]] + assert names == ["重点", "竞品"] + + @pytest.mark.asyncio + async def test_attaching_replaces_the_whole_set(self, client): + await _seed() + first = await _make_tag(client, "重点") + second = await _make_tag(client, "竞品") + await _attach(client, "note-a", [first, second]) + + await _attach(client, "note-a", [second]) + + assert [t["name"] for t in (await _notes(client))[0]["tags"]] == ["竞品"] + + @pytest.mark.asyncio + async def test_an_empty_list_clears_them(self, client): + await _seed() + tag_id = await _make_tag(client, "重点") + await _attach(client, "note-a", [tag_id]) + + await _attach(client, "note-a", []) + + assert (await _notes(client))[0]["tags"] == [] + + @pytest.mark.asyncio + async def test_unknown_tag_ids_are_ignored_not_stored(self, client): + """前端传了脏 id(比如标签刚被删掉)时,别在关联表里留下孤儿。""" + await _seed() + tag_id = await _make_tag(client, "重点") + + await _attach(client, "note-a", [tag_id, 99999]) + + assert [t["id"] for t in (await _notes(client))[0]["tags"]] == [tag_id] + assert (await _tags(client))[0]["note_count"] == 1 + + @pytest.mark.asyncio + async def test_works_start_without_tags(self, client): + await _seed() + + assert (await _notes(client))[0]["tags"] == [] + + @pytest.mark.asyncio + async def test_tags_do_not_leak_to_another_platform(self, client): + """作品的 id 分平台编号体系 —— 抖音的 123 和小红书的 123 是两条作品。""" + await _seed(platform="xhs") + await _seed(platform="dy") + tag_id = await _make_tag(client, "重点") + + await _attach(client, "note-a", [tag_id], platform="xhs") + + assert (await _notes(client, platform="xhs"))[0]["tags"] != [] + assert (await _notes(client, platform="dy"))[0]["tags"] == [] + + @pytest.mark.asyncio + async def test_deleting_a_tag_unsticks_it_everywhere(self, client): + await _seed(note_ids=("note-a", "note-b")) + tag_id = await _make_tag(client, "重点") + await _attach(client, "note-a", [tag_id]) + await _attach(client, "note-b", [tag_id]) + + response = await client.delete(f"/api/monitor/tags/{tag_id}") + + assert response.json()["removed"] == 2 + assert (await _tags(client)) == [] + for note in await _notes(client): + assert note["tags"] == [] + + +class TestFilteringByTag: + @pytest.mark.asyncio + async def test_only_works_wearing_the_tag_come_back(self, client): + await _seed(note_ids=("note-a", "note-b")) + tag_id = await _make_tag(client, "重点") + await _attach(client, "note-a", [tag_id]) + + notes = await _notes(client, tag_id=tag_id) + + assert [note["note_id"] for note in notes] == ["note-a"] + + @pytest.mark.asyncio + async def test_several_tags_means_any_of_them(self, client): + """多选是「或」不是「且」—— 想同时满足两个标签的做法是再点一个标签,而不是 + 指望筛选去求交集。""" + await _seed(note_ids=("note-a", "note-b", "note-c")) + first = await _make_tag(client, "重点") + second = await _make_tag(client, "竞品") + await _attach(client, "note-a", [first]) + await _attach(client, "note-b", [second]) + + notes = await _notes(client, tag_id=[first, second]) + + assert sorted(note["note_id"] for note in notes) == ["note-a", "note-b"] + + @pytest.mark.asyncio + async def test_no_filter_returns_everything(self, client): + await _seed(note_ids=("note-a", "note-b")) + + assert len(await _notes(client)) == 2 diff --git a/webui/src/components/monitor/MonitorDashboard.tsx b/webui/src/components/monitor/MonitorDashboard.tsx index 10312b6..19b2eb7 100644 --- a/webui/src/components/monitor/MonitorDashboard.tsx +++ b/webui/src/components/monitor/MonitorDashboard.tsx @@ -13,6 +13,7 @@ import { UnwiredPlatformNotice } from '@/components/layout/UnwiredPlatformNotice import { CommentsFeed } from './CommentsFeed' import { EventsFeed } from './EventsFeed' import { NotesTable } from './NotesTable' +import { TagFilter } from './TagFilter' import { RunHistory } from './RunHistory' import { StatTile } from './StatTile' import { TaskCard } from './TaskCard' @@ -32,6 +33,10 @@ export function MonitorDashboard() { const [editorOpen, setEditorOpen] = useState(false) const [editingTask, setEditingTask] = useState(null) const [onlyNew, setOnlyNew] = useState(false) + // 选中的标签。空数组 = 不筛。 + // 换任务时**刻意不清**:选中了哪几个标签在筛选条上是看得见的,空列表不会没头没脑; + // 而切个任务就把筛选悄悄清掉,反而会让人以为筛选没生效。 + const [tagIds, setTagIds] = useState([]) // Fall back to the first task so the detail panes are never empty for no // reason; keep an explicit selection once the user makes one. @@ -170,6 +175,7 @@ export function MonitorDashboard() { {/* 导出的是**这个任务**的全部作品,不是屏幕上这 200 条 —— 屏幕上那份是 为了好看才截断的,导出跟着截断就成了「导出来的比看到的少」。 走 window.open 而不是 blob:鉴权在 Cookie 上,浏览器自己会带上。 */} + + )} + + + + + + ) +} diff --git a/webui/src/components/monitor/NotesTable.tsx b/webui/src/components/monitor/NotesTable.tsx index fc0c927..8d0db1c 100644 --- a/webui/src/components/monitor/NotesTable.tsx +++ b/webui/src/components/monitor/NotesTable.tsx @@ -1,5 +1,5 @@ import { Fragment, useMemo, useRef, useState } from 'react' -import { ChevronDown, ChevronRight, ExternalLink, Pencil, Users } from 'lucide-react' +import { ChevronDown, ChevronRight, ExternalLink, Pencil, Tag, Users } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { useMonitorNotes, useSetCreatorAlias, useSetNoteAlias } from '@/hooks/useMonitor' @@ -10,13 +10,18 @@ import { formatDelta, formatRelative, } from '@/lib/monitorFormat' +import { cn } from '@/lib/utils' +import { tagColorClass } from '@/lib/tagColors' import type { MonitorCreator, MonitorNote, NoteMetrics } from '@/types/monitor' import { NoteCover } from './NoteCover' +import { NoteTagPicker } from './NoteTagPicker' import { NoteTrendChart } from './NoteTrendChart' interface NotesTableProps { taskId: number | null onlyNew: boolean + /** 按标签筛。空数组 = 不筛。命中的是**任意一个**选中的标签。 */ + tagIds?: number[] } /** A metric with its change vs the previous run underneath. */ @@ -128,8 +133,8 @@ function CreatorStats({ stats }: { stats: AccountStats }) { * `creator_hash` —— 爬虫刻意不落原始 user_id,所以这是唯一稳定的创作者标识; * 显示名是已脱敏的昵称(张***三)。两者都认不出来时才退回"未知博主"。 */ -export function NotesTable({ taskId, onlyNew }: NotesTableProps) { - const { data, isLoading } = useMonitorNotes(taskId, onlyNew) +export function NotesTable({ taskId, onlyNew, tagIds = [] }: NotesTableProps) { + const { data, isLoading } = useMonitorNotes(taskId, onlyNew, tagIds) const notes = data?.notes const creators = data?.creators const [expandedNote, setExpandedNote] = useState(null) @@ -147,6 +152,8 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) { const [noteAliasDraft, setNoteAliasDraft] = useState('') const noteAliasCancelled = useRef(false) const setNoteAlias = useSetNoteAlias() + // 正在贴标签的那条作品。null = 对话框没开。 + const [taggingNote, setTaggingNote] = useState(null) const commitAlias = (creatorHash: string) => { if (aliasCancelled.current) { @@ -455,6 +462,20 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) { /> ) : (
+ {/* 标签放在这一行而不是标题行:标题行已经有 NEW、备注 + 和跳转,再塞几个标签就糊成一团了。标签跟着 id 和 + 快照数走,是同一层的元信息。 */} + {note.tags.map((tag) => ( + + {tag.name} + + ))} {note.note_id} · {note.snapshot_count} 次快照 @@ -470,6 +491,17 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) { {note.note_alias ? '改备注' : '备注'} +
)} @@ -514,6 +546,19 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) { })} + + {/* 对话框挂在表格**外面** —— 放在 tbody 里会因为表格的布局规则出问题。 */} + {taggingNote && ( + { + if (!open) setTaggingNote(null) + }} + /> + )} ) } diff --git a/webui/src/components/monitor/TagFilter.tsx b/webui/src/components/monitor/TagFilter.tsx new file mode 100644 index 0000000..938b268 --- /dev/null +++ b/webui/src/components/monitor/TagFilter.tsx @@ -0,0 +1,67 @@ +import { X } from 'lucide-react' + +import { useMonitorTags } from '@/hooks/useMonitor' +import { cn } from '@/lib/utils' +import { tagColorClass } from '@/lib/tagColors' + +/** + * 按标签筛作品。 + * + * 做成**平铺的开关片**而不是下拉多选:词表是人自己维护的,通常就几个,全摆出来一眼就 + * 知道有哪些、选中了哪些 —— 下拉还得点开才知道里面有什么。 + * + * 多选是**或**:选中「重点」和「竞品」= 两类都看。求交集的做法是再点一个标签,而不是 + * 让筛选去猜。 + * + * 一个标签都没有时整块不画 —— 空的筛选条只是占地方。 + */ +export function TagFilter({ + selected, + onChange, +}: { + selected: number[] + onChange: (tagIds: number[]) => void +}) { + const { data: tags } = useMonitorTags() + + if (!tags || tags.length === 0) return null + + const toggle = (tagId: number) => + onChange( + selected.includes(tagId) + ? selected.filter((id) => id !== tagId) + : [...selected, tagId], + ) + + return ( +
+ {tags.map((tag) => { + const active = selected.includes(tag.id) + return ( + + ) + })} + {selected.length > 0 && ( + + )} +
+ ) +} diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 3988849..4e5916b 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -6,6 +6,7 @@ import { CookiePanel } from '@/components/monitor/CookiePanel' import { QrLoginPanel } from '@/components/monitor/QrLoginPanel' import { RemoteBrowserPanel } from '@/components/settings/RemoteBrowserPanel' import { Section, SettingField } from '@/components/settings/SettingFields' +import { TagSettings } from '@/components/settings/TagSettings' import { useSettings, useUpdateSettings } from '@/hooks/useMonitor' import { useCurrentPlatform } from '@/hooks/usePlatform' import { useCrawlerStore } from '@/store/crawlerStore' @@ -99,6 +100,8 @@ export function SettingsView({ onNavigate }: { onNavigate?: (view: AppView) => v + +
(TAG_COLOR_CHOICES[0]) + // 正在改名的那一个(标签 id)。 + const [editingId, setEditingId] = useState(null) + const [editName, setEditName] = useState('') + const [editColor, setEditColor] = useState(TAG_COLOR_CHOICES[0]) + + const submit = () => { + const name = draftName.trim() + if (!name) return + createTag.mutate( + { name, color: draftColor }, + { onSuccess: () => setDraftName('') }, + ) + } + + const startEdit = (tag: MonitorTag) => { + setEditingId(tag.id) + setEditName(tag.name) + setEditColor(tag.color) + } + + const saveEdit = () => { + if (editingId === null) return + const name = editName.trim() + if (!name) return + updateTag.mutate({ id: editingId, name, color: editColor }) + setEditingId(null) + } + + return ( +
+
+ {isLoading && ( +

加载中…

+ )} + {tags?.length === 0 && ( +

+ 还没有标签。在下面加一个,比如「重点」「竞品」。 +

+ )} + + {tags?.map((tag) => + editingId === tag.id ? ( +
+ setEditName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') saveEdit() + if (event.key === 'Escape') setEditingId(null) + }} + className="w-40 rounded border border-cyber-neon-cyan/40 bg-cyber-bg-tertiary px-1.5 py-0.5 text-[11px] font-mono text-cyber-text-primary outline-none" + /> + + + +
+ ) : ( +
+ + {tag.name} + + + {tag.note_count > 0 ? `用在 ${tag.note_count} 条作品上` : '还没用过'} + +
+ + +
+
+ ), + )} + +
+ setDraftName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') submit() + }} + className="w-40 rounded border border-cyber-border-DEFAULT bg-cyber-bg-tertiary px-1.5 py-0.5 text-[11px] font-mono text-cyber-text-primary outline-none focus:border-cyber-neon-cyan/40" + /> + + +
+
+
+ ) +} + +/** 五个颜色的小圆点。**不是取色器** —— 颜色得是固定的几个,才能和主题对得上。 */ +function ColorPicker({ + value, + onChange, +}: { + value: string + onChange: (color: string) => void +}) { + return ( +
+ {TAG_COLOR_CHOICES.map((color) => ( +
+ ) +} diff --git a/webui/src/hooks/useMonitor.ts b/webui/src/hooks/useMonitor.ts index 37db5e9..6bdff90 100644 --- a/webui/src/hooks/useMonitor.ts +++ b/webui/src/hooks/useMonitor.ts @@ -104,16 +104,82 @@ export function useTaskRuns(taskId: number | null) { * 最该显示的一类。所以服务端单独给一份 `creators`,两者必须来自同一次请求,否则 * 组头和组里的作品会对不上(比如刚删掉一个任务)。 */ -export function useMonitorNotes(taskId: number | null, onlyNew: boolean) { +export function useMonitorNotes( + taskId: number | null, + onlyNew: boolean, + tagIds: number[] = [], +) { const platform = usePlatformParam() return useQuery({ - queryKey: ['monitorNotes', platform, taskId, onlyNew], + queryKey: ['monitorNotes', platform, taskId, onlyNew, tagIds], queryFn: async () => - (await monitorApi.getNotes(taskId ?? undefined, onlyNew, 200, platform)).data, + (await monitorApi.getNotes(taskId ?? undefined, onlyNew, 200, platform, tagIds)).data, refetchInterval: POLL_MS, }) } +// --- 标签 ------------------------------------------------------------------ + +export function useMonitorTags() { + return useQuery({ + queryKey: ['monitorTags'], + queryFn: async () => (await monitorApi.listTags()).data.tags, + // 词表是人手工维护的,没有别的写入方,不用频繁刷。 + staleTime: 60_000, + }) +} + +/** 标签变了要让作品列表重取 —— 标签是画在作品行上的。 */ +function useTagMutation( + mutationFn: (args: TArgs) => Promise, + successMessage: string, +) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn, + onSuccess: () => { + toast.success(successMessage) + queryClient.invalidateQueries({ queryKey: ['monitorTags'] }) + queryClient.invalidateQueries({ queryKey: ['monitorNotes'] }) + }, + onError: (error: Error) => toast.error(`操作失败:${error.message}`), + }) +} + +export function useCreateTag() { + return useTagMutation( + ({ name, color }: { name: string; color: string }) => monitorApi.createTag(name, color), + '标签已创建', + ) +} + +export function useUpdateTag() { + return useTagMutation( + ({ id, name, color }: { id: number; name: string; color: string }) => + monitorApi.updateTag(id, name, color), + '标签已保存', + ) +} + +export function useDeleteTag() { + return useTagMutation((id: number) => monitorApi.deleteTag(id), '标签已删除') +} + +export function useSetNoteTags() { + const queryClient = useQueryClient() + const platform = usePlatformParam() + return useMutation({ + mutationFn: ({ noteId, tagIds }: { noteId: string; tagIds: number[] }) => + monitorApi.setNoteTags(noteId, tagIds, platform), + onSuccess: () => { + // 不打 toast:贴标签是个连着点好几次的动作,每次都弹一条会把屏幕刷满。 + queryClient.invalidateQueries({ queryKey: ['monitorNotes'] }) + queryClient.invalidateQueries({ queryKey: ['monitorTags'] }) + }, + onError: (error: Error) => toast.error(`标签保存失败:${error.message}`), + }) +} + export function useNoteSeries(noteId: string | null, taskId: number | null) { return useQuery({ queryKey: ['monitorNoteSeries', noteId, taskId], diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index ae00a83..6c665f9 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -10,6 +10,7 @@ import type { MonitorNote, MonitorOverview, MonitorRun, + MonitorTag, MonitorTask, LoginState, PlatformCapability, @@ -186,9 +187,16 @@ export const monitorApi = { getRuns: (id: number, limit = 50) => 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, + tagIds?: number[], + ) => api.get<{ notes: MonitorNote[]; creators: MonitorCreator[] }>('/monitor/notes', { - params: { task_id: taskId, only_new: onlyNew, limit, platform }, + // tag_id 可重复传 —— axios 会把数组序列化成 tag_id=1&tag_id=2,正是后端要的形状。 + params: { task_id: taskId, only_new: onlyNew, limit, platform, tag_id: tagIds }, }), // Metric time series for one note; the chart reads this. getNoteSeries: (noteId: string, taskId?: number) => @@ -291,6 +299,23 @@ export const monitorApi = { { alias }, { params: { platform } }, ), + // --- 标签 --------------------------------------------------------------- + + /** 标签词表(设置页维护),带各自被用在多少条作品上。 */ + listTags: () => api.get<{ tags: MonitorTag[] }>('/monitor/tags'), + createTag: (name: string, color = '') => + api.post('/monitor/tags', { name, color }), + updateTag: (id: number, name: string, color = '') => + api.put(`/monitor/tags/${id}`, { name, color }), + deleteTag: (id: number) => api.delete<{ removed: number }>(`/monitor/tags/${id}`), + /** 把一条作品的标签**整体替换**成这一组。空数组 = 全摘掉。 */ + setNoteTags: (noteId: string, tagIds: number[], platform?: string) => + api.put( + `/monitor/notes/${encodeURIComponent(noteId)}/tags`, + { tag_ids: tagIds }, + { params: { platform } }, + ), + /** 给作品起备注(空串 = 清掉)。键是 note_id —— 和博主备注是两回事。 */ setNoteAlias: (noteId: string, alias: string, platform?: string) => api.put( diff --git a/webui/src/lib/tagColors.ts b/webui/src/lib/tagColors.ts new file mode 100644 index 0000000..b8ac994 --- /dev/null +++ b/webui/src/lib/tagColors.ts @@ -0,0 +1,34 @@ +/** + * 标签颜色:调色板名字 → Tailwind 类名。 + * + * **必须写死。** Tailwind 是在构建时静态扫描源码找类名的,`text-cyber-neon-${color}` + * 这种拼出来的字符串它看不见 —— 而且不会报错,只是线上那些标签全变成无色。所以这里 + * 是一张查表,后端存的是键(cyan / pink / …)。 + */ +const TAG_COLOR_CLASSES: Record = { + 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', +} + +/** 设置页里让用户选的那几个。键要和后端 `service.TAG_COLORS` 一致。 */ +export const TAG_COLOR_CHOICES = ['cyan', 'pink', 'green', 'orange', 'muted'] as const + +const COLOR_LABELS: Record = { + cyan: '青', + pink: '粉', + green: '绿', + orange: '橙', + muted: '灰', +} + +export function tagColorClass(color: string): string { + // 认不出来的颜色回落到默认色,而不是整块没有样式 —— 后端也会这么回落,两边一致。 + return TAG_COLOR_CLASSES[color] ?? TAG_COLOR_CLASSES.cyan +} + +export function tagColorLabel(color: string): string { + return COLOR_LABELS[color] ?? COLOR_LABELS.cyan +} diff --git a/webui/src/types/monitor.ts b/webui/src/types/monitor.ts index e0126b2..599d011 100644 --- a/webui/src/types/monitor.ts +++ b/webui/src/types/monitor.ts @@ -109,6 +109,13 @@ export interface MonitorNote { * 一个博主底下常常只有一两件值得盯的作品,所以不能合并成一条。空串表示没起过。 */ note_alias: string + /** + * 贴在这条作品上的标签 —— 从设置里那份**固定词表**选的分类,可以贴好几个。 + * + * 和 `note_alias` 并存、不是一回事:备注是一句话的自由文字,标签是封闭词表里的分类。 + * 分类得是封闭的,否则「重点 / 重要 / 优先」各写各的,筛选就没法用了。 + */ + tags: TagRef[] /** * 博主**账号级**指标 —— 作品列表给不了的东西:作品说的是「这条涨了多少赞」, * 它说的是「这个人整个账号在涨还是在掉」。 @@ -168,6 +175,21 @@ export interface MonitorCreator { last_activity_at: number } +/** + * 一个标签。`color` 是**调色板里的名字**(cyan/pink/…),不是颜色值 —— Tailwind 的类名 + * 是静态提取的,拼不出动态类名,所以要用一张写死的表去查。 + */ +export interface TagRef { + id: number + name: string + color: string +} + +/** 词表里的标签,额外带一个「被用在多少条作品上」—— 删之前要能看清代价。 */ +export interface MonitorTag extends TagRef { + note_count: number +} + export interface MetricPoint { run_id: number captured_at: number