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
+44
View File
@@ -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."""
+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,
+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"),
+13
View File
@@ -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 地址,留空表示停用")
+237
View File
@@ -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
@@ -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<MonitorTask | null>(null)
const [onlyNew, setOnlyNew] = useState(false)
// 选中的标签。空数组 = 不筛。
// 换任务时**刻意不清**:选中了哪几个标签在筛选条上是看得见的,空列表不会没头没脑;
// 而切个任务就把筛选悄悄清掉,反而会让人以为筛选没生效。
const [tagIds, setTagIds] = useState<number[]>([])
// 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 上,浏览器自己会带上。 */}
<TagFilter selected={tagIds} onChange={setTagIds} />
<Button
variant="outline"
size="sm"
@@ -185,7 +191,7 @@ export function MonitorDashboard() {
导出 CSV
</Button>
</div>
<NotesTable taskId={selectedTask.id} onlyNew={onlyNew} />
<NotesTable taskId={selectedTask.id} onlyNew={onlyNew} tagIds={tagIds} />
</TabsContent>
<TabsContent value="comments" className="flex-1 overflow-auto terminal-scroll px-3 pb-3 mt-2">
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react'
import { Tag } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useMonitorTags, useSetNoteTags } from '@/hooks/useMonitor'
import { cn } from '@/lib/utils'
import { tagColorClass } from '@/lib/tagColors'
import type { TagRef } from '@/types/monitor'
interface NoteTagPickerProps {
noteId: string
noteTitle: string
/** 这条作品现在贴着的标签,用来给复选框定初值。 */
current: TagRef[]
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* 给一条作品贴标签。
*
* 用对话框而不是下拉气泡:这是个**多选**,几个复选框摆开比塞进一个小浮层清楚得多,
* 也不用为此引一个新的依赖。
*
* 点「保存」是一次**全量替换**(这条作品现在该有哪些标签),不是逐个增删 —— 复选框
* 表达的就是一个集合,对齐差集只是徒增错处。
*/
export function NoteTagPicker({
noteId,
noteTitle,
current,
open,
onOpenChange,
}: NoteTagPickerProps) {
const { data: tags } = useMonitorTags()
const setNoteTags = useSetNoteTags()
const [selected, setSelected] = useState<number[]>([])
// 每次打开都从「现在的标签」重新起算,否则上一次的勾选会串到另一条作品上。
useEffect(() => {
if (open) setSelected(current.map((tag) => tag.id))
// current 是每次渲染新建的数组,放进依赖会让这个 effect 每帧都跑;只在打开时同步。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, noteId])
const toggle = (tagId: number) =>
setSelected((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
)
const save = () => {
setNoteTags.mutate({ noteId, tagIds: selected })
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-xs font-mono">给作品贴标签</DialogTitle>
<DialogDescription className="text-[10px] font-mono truncate">
{noteTitle || noteId}
</DialogDescription>
</DialogHeader>
{tags && tags.length > 0 ? (
<div className="space-y-1.5">
{tags.map((tag) => (
<label
key={tag.id}
className="flex items-center gap-2 rounded px-1.5 py-1 cursor-pointer hover:bg-cyber-bg-elevated/50"
>
<Checkbox
checked={selected.includes(tag.id)}
onCheckedChange={() => toggle(tag.id)}
/>
<span
className={cn(
'rounded border px-1.5 py-0 text-[10px] font-mono',
tagColorClass(tag.color),
)}
>
{tag.name}
</span>
</label>
))}
</div>
) : (
<p className="text-[11px] font-mono text-cyber-text-muted">
还没有标签。先去「设置 → 作品标签」里加几个。
</p>
)}
<DialogFooter className="gap-2">
{selected.length > 0 && (
<Button
variant="ghost"
size="sm"
className="mr-auto"
onClick={() => setSelected([])}
>
<Tag className="w-3 h-3 mr-1" />
全摘掉
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button size="sm" onClick={save} disabled={setNoteTags.isPending}>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+48 -3
View File
@@ -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<string | null>(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<MonitorNote | null>(null)
const commitAlias = (creatorHash: string) => {
if (aliasCancelled.current) {
@@ -455,6 +462,20 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
/>
) : (
<div className="flex items-center gap-1.5 text-[9px] text-cyber-text-muted">
{/* 标签放在这一行而不是标题行:标题行已经有 NEW、备注
和跳转,再塞几个标签就糊成一团了。标签跟着 id 和
快照数走,是同一层的元信息。 */}
{note.tags.map((tag) => (
<span
key={tag.id}
className={cn(
'rounded border px-1 py-0 text-[9px] font-mono flex-shrink-0',
tagColorClass(tag.color),
)}
>
{tag.name}
</span>
))}
<span className="truncate">
{note.note_id} · {note.snapshot_count} 次快照
</span>
@@ -470,6 +491,17 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
<Pencil className="w-2.5 h-2.5" />
{note.note_alias ? '改备注' : '备注'}
</button>
<button
onClick={(event) => {
event.stopPropagation()
setTaggingNote(note)
}}
title="给这条作品贴标签"
className="flex items-center gap-0.5 flex-shrink-0 text-cyber-text-muted hover:text-cyber-neon-cyan"
>
<Tag className="w-2.5 h-2.5" />
标签
</button>
</div>
)}
</div>
@@ -514,6 +546,19 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
})}
</tbody>
</table>
{/* 对话框挂在表格**外面** —— 放在 tbody 里会因为表格的布局规则出问题。 */}
{taggingNote && (
<NoteTagPicker
noteId={taggingNote.note_id}
noteTitle={taggingNote.title}
current={taggingNote.tags}
open={taggingNote !== null}
onOpenChange={(open) => {
if (!open) setTaggingNote(null)
}}
/>
)}
</div>
)
}
@@ -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 (
<div className="flex items-center gap-1 flex-wrap">
{tags.map((tag) => {
const active = selected.includes(tag.id)
return (
<button
key={tag.id}
onClick={() => toggle(tag.id)}
title={active ? '取消这个筛选' : `只看「${tag.name}」`}
className={cn(
'rounded border px-1.5 py-0.5 text-[10px] font-mono transition-colors',
active
? cn(tagColorClass(tag.color), 'bg-cyber-bg-elevated')
: 'border-cyber-border-subtle text-cyber-text-muted hover:text-cyber-text-secondary',
)}
>
{tag.name}
</button>
)
})}
{selected.length > 0 && (
<button
onClick={() => onChange([])}
className="flex items-center gap-0.5 text-[10px] font-mono text-cyber-text-muted hover:text-cyber-neon-pink"
>
<X className="w-3 h-3" />
清除
</button>
)}
</div>
)
}
@@ -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
</div>
</Section>
<TagSettings />
<Section
title="采集策略"
description="各平台风控容忍度不同,因此按平台分别配置。"
@@ -0,0 +1,195 @@
import { useState } from 'react'
import { Check, Pencil, Plus, Trash2, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Section } from '@/components/settings/SettingFields'
import { useCreateTag, useDeleteTag, useMonitorTags, useUpdateTag } from '@/hooks/useMonitor'
import { cn } from '@/lib/utils'
import { TAG_COLOR_CHOICES, tagColorClass, tagColorLabel } from '@/lib/tagColors'
import type { MonitorTag } from '@/types/monitor'
/**
* 标签词表 —— 作品上能贴的标签在这里维护。
*
* 这是一份**封闭的词表**,而不是让每人在作品上随手写:分类只有在词固定下来之后才能
* 用来筛选,否则「重点 / 重要 / 优先」会各写各的,最后谁也筛不出东西。
*
* 它和「作品备注」是两件事,所以没有合并:备注是一句话的自由文字(「这条标题起得好」),
* 需要的是自由;标签是分类,需要的恰好是封闭。
*/
export function TagSettings() {
const { data: tags, isLoading } = useMonitorTags()
const createTag = useCreateTag()
const updateTag = useUpdateTag()
const deleteTag = useDeleteTag()
const [draftName, setDraftName] = useState('')
const [draftColor, setDraftColor] = useState<string>(TAG_COLOR_CHOICES[0])
// 正在改名的那一个(标签 id)。
const [editingId, setEditingId] = useState<number | null>(null)
const [editName, setEditName] = useState('')
const [editColor, setEditColor] = useState<string>(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 (
<Section
title="作品标签"
description="作品上能贴的标签在这里维护。剪掉一个标签,所有作品上的它也会一起消失。"
>
<div className="space-y-2">
{isLoading && (
<p className="text-[11px] font-mono text-cyber-text-muted">加载中…</p>
)}
{tags?.length === 0 && (
<p className="text-[11px] font-mono text-cyber-text-muted">
还没有标签。在下面加一个,比如「重点」「竞品」。
</p>
)}
{tags?.map((tag) =>
editingId === tag.id ? (
<div key={tag.id} className="flex items-center gap-2">
<input
autoFocus
value={editName}
onChange={(event) => 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"
/>
<ColorPicker value={editColor} onChange={setEditColor} />
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={saveEdit}>
<Check className="w-3 h-3 text-cyber-neon-green" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5"
onClick={() => setEditingId(null)}
>
<X className="w-3 h-3" />
</Button>
</div>
) : (
<div key={tag.id} className="flex items-center gap-2">
<Badge
className={cn('text-[10px] px-1.5 py-0', tagColorClass(tag.color))}
variant="outline"
>
{tag.name}
</Badge>
<span className="text-[10px] font-mono text-cyber-text-muted">
{tag.note_count > 0 ? `用在 ${tag.note_count} 条作品上` : '还没用过'}
</span>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5"
title="改名 / 换颜色"
onClick={() => startEdit(tag)}
>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 hover:text-cyber-neon-pink"
title={
tag.note_count > 0
? `删除后会从 ${tag.note_count} 条作品上一并消失`
: '删除'
}
// 用过才确认 —— 0 条的那种点错了也没代价,多一次弹窗只是烦。
onClick={() => {
if (
tag.note_count === 0 ||
window.confirm(
`「${tag.name}」正贴在 ${tag.note_count} 条作品上,删掉之后它们也会一并摘掉。继续?`,
)
) {
deleteTag.mutate(tag.id)
}
}}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
),
)}
<div className="flex items-center gap-2 pt-2 mt-1 border-t border-cyber-border-subtle">
<input
value={draftName}
placeholder="新标签,比如「重点」"
onChange={(event) => 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"
/>
<ColorPicker value={draftColor} onChange={setDraftColor} />
<Button size="sm" disabled={!draftName.trim() || createTag.isPending} onClick={submit}>
<Plus className="w-3 h-3 mr-1" />
添加
</Button>
</div>
</div>
</Section>
)
}
/** 五个颜色的小圆点。**不是取色器** —— 颜色得是固定的几个,才能和主题对得上。 */
function ColorPicker({
value,
onChange,
}: {
value: string
onChange: (color: string) => void
}) {
return (
<div className="flex items-center gap-1">
{TAG_COLOR_CHOICES.map((color) => (
<button
key={color}
type="button"
title={tagColorLabel(color)}
onClick={() => onChange(color)}
className={cn(
'h-4 w-4 rounded-full border transition-all',
tagColorClass(color),
value === color
? 'ring-1 ring-offset-1 ring-cyber-text-secondary ring-offset-cyber-bg-secondary'
: 'opacity-60 hover:opacity-100',
)}
/>
))}
</div>
)
}
+69 -3
View File
@@ -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<TArgs>(
mutationFn: (args: TArgs) => Promise<unknown>,
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],
+27 -2
View File
@@ -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<MonitorTag>('/monitor/tags', { name, color }),
updateTag: (id: number, name: string, color = '') =>
api.put<MonitorTag>(`/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(
+34
View File
@@ -0,0 +1,34 @@
/**
* 标签颜色:调色板名字 → Tailwind 类名。
*
* **必须写死。** Tailwind 是在构建时静态扫描源码找类名的,`text-cyber-neon-${color}`
* 这种拼出来的字符串它看不见 —— 而且不会报错,只是线上那些标签全变成无色。所以这里
* 是一张查表,后端存的是键(cyan / pink / …)。
*/
const TAG_COLOR_CLASSES: Record<string, string> = {
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<string, string> = {
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
}
+22
View File
@@ -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