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