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)
|
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):
|
class MonitorSetting(MonitorBase):
|
||||||
"""Key/value store. Holds the XHS cookie for unattended runs."""
|
"""Key/value store. Holds the XHS cookie for unattended runs."""
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ from .models import (
|
|||||||
MonitorNote,
|
MonitorNote,
|
||||||
MonitorNoteAlias,
|
MonitorNoteAlias,
|
||||||
MonitorNoteMetric,
|
MonitorNoteMetric,
|
||||||
|
MonitorNoteTag,
|
||||||
|
MonitorTag,
|
||||||
MonitorRun,
|
MonitorRun,
|
||||||
MonitorTarget,
|
MonitorTarget,
|
||||||
MonitorTask,
|
MonitorTask,
|
||||||
@@ -429,6 +431,184 @@ async def set_note_alias(
|
|||||||
existing.updated_at = get_current_timestamp()
|
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(
|
async def _latest_creator_stats(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
task_ids: Optional[Sequence[int]] = None,
|
task_ids: Optional[Sequence[int]] = None,
|
||||||
@@ -517,6 +697,7 @@ async def list_notes(
|
|||||||
platform: Optional[str] = None,
|
platform: Optional[str] = None,
|
||||||
*,
|
*,
|
||||||
windowed: bool = False,
|
windowed: bool = False,
|
||||||
|
tag_ids: Optional[Sequence[int]] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Tracked notes with their latest metrics and change vs the previous run.
|
"""Tracked notes with their latest metrics and change vs the previous run.
|
||||||
|
|
||||||
@@ -537,6 +718,17 @@ async def list_notes(
|
|||||||
return []
|
return []
|
||||||
query = query.where(MonitorNote.task_id.in_(scoped))
|
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())
|
notes = list((await session.scalars(query)).all())
|
||||||
if not notes:
|
if not notes:
|
||||||
return []
|
return []
|
||||||
@@ -581,6 +773,7 @@ async def list_notes(
|
|||||||
}
|
}
|
||||||
aliases = await _creator_alias_map(session)
|
aliases = await _creator_alias_map(session)
|
||||||
note_aliases = await _note_alias_map(session)
|
note_aliases = await _note_alias_map(session)
|
||||||
|
note_tags = await _note_tag_map(session)
|
||||||
# 账号级指标(粉丝 / 总获赞 / 作品数)。**挂在作品上一起返回**,因为界面上就是按博主
|
# 账号级指标(粉丝 / 总获赞 / 作品数)。**挂在作品上一起返回**,因为界面上就是按博主
|
||||||
# 归组显示的 —— 让前端为了一个组头再发一轮请求没道理。同一个博主的所有作品拿到的是
|
# 归组显示的 —— 让前端为了一个组头再发一轮请求没道理。同一个博主的所有作品拿到的是
|
||||||
# 同一条(键里带 task_id,所以跨任务不会串)。没有的(小红书那条路不产生它)就是 null,
|
# 同一条(键里带 task_id,所以跨任务不会串)。没有的(小红书那条路不产生它)就是 null,
|
||||||
@@ -629,6 +822,9 @@ async def list_notes(
|
|||||||
"note_alias": note_aliases.get(
|
"note_alias": note_aliases.get(
|
||||||
(task_platform.get(note.task_id, ""), note.note_id), ""
|
(task_platform.get(note.task_id, ""), note.note_id), ""
|
||||||
),
|
),
|
||||||
|
# 贴在这条作品上的标签。备注是自由文字(一句话说明),标签是从设置里那份
|
||||||
|
# 词表选的分类 —— 两者互补,不是一回事。
|
||||||
|
"tags": note_tags.get((task_platform.get(note.task_id, ""), note.note_id), []),
|
||||||
# 博主账号级指标 —— 作品列表给不了的东西。三个值都可能为 null(平台没采
|
# 博主账号级指标 —— 作品列表给不了的东西。三个值都可能为 null(平台没采
|
||||||
# 到、或者这条作品来自不产生它的数据源),前端据此整块不画。
|
# 到、或者这条作品来自不产生它的数据源),前端据此整块不画。
|
||||||
"creator_fans": stat.fans if stat else None,
|
"creator_fans": stat.fans if stat else None,
|
||||||
|
|||||||
+60
-1
@@ -40,6 +40,8 @@ from ..schemas.monitor import (
|
|||||||
CookiePayload,
|
CookiePayload,
|
||||||
CreatorAliasPayload,
|
CreatorAliasPayload,
|
||||||
NoteAliasPayload,
|
NoteAliasPayload,
|
||||||
|
NoteTagsPayload,
|
||||||
|
TagPayload,
|
||||||
MonitorTaskCreate,
|
MonitorTaskCreate,
|
||||||
MonitorTaskUpdate,
|
MonitorTaskUpdate,
|
||||||
WebhookPayload,
|
WebhookPayload,
|
||||||
@@ -130,13 +132,16 @@ async def list_notes(
|
|||||||
only_new: bool = False,
|
only_new: bool = False,
|
||||||
limit: int = Query(default=200, ge=1, le=2000),
|
limit: int = Query(default=200, ge=1, le=2000),
|
||||||
platform: Optional[str] = None,
|
platform: Optional[str] = None,
|
||||||
|
tag_id: Optional[List[int]] = Query(
|
||||||
|
default=None, description="可重复传,命中其中任意一个标签即算"
|
||||||
|
),
|
||||||
):
|
):
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
return {
|
return {
|
||||||
# windowed=True:作品栏是「正在盯的窗口」,每个博主只画最新 max_notes_count 条。
|
# windowed=True:作品栏是「正在盯的窗口」,每个博主只画最新 max_notes_count 条。
|
||||||
# 库里的全量还在,趋势图/报表/导出读的都是它。
|
# 库里的全量还在,趋势图/报表/导出读的都是它。
|
||||||
"notes": await service.list_notes(
|
"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()}
|
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
|
# QR login
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -416,6 +437,44 @@ async def _resolve_scope(
|
|||||||
return [task for task in task_ids if task in platform_ids]
|
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")
|
@router.get("/export")
|
||||||
async def export_data(
|
async def export_data(
|
||||||
kind: str = Query(..., description="notes | comments | report"),
|
kind: str = Query(..., description="notes | comments | report"),
|
||||||
|
|||||||
@@ -120,6 +120,19 @@ class NoteAliasPayload(BaseModel):
|
|||||||
alias: str = Field(default="", max_length=128)
|
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):
|
class WebhookPayload(BaseModel):
|
||||||
url: str = Field(default="", description="企业微信机器人 Webhook 地址,留空表示停用")
|
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 { CommentsFeed } from './CommentsFeed'
|
||||||
import { EventsFeed } from './EventsFeed'
|
import { EventsFeed } from './EventsFeed'
|
||||||
import { NotesTable } from './NotesTable'
|
import { NotesTable } from './NotesTable'
|
||||||
|
import { TagFilter } from './TagFilter'
|
||||||
import { RunHistory } from './RunHistory'
|
import { RunHistory } from './RunHistory'
|
||||||
import { StatTile } from './StatTile'
|
import { StatTile } from './StatTile'
|
||||||
import { TaskCard } from './TaskCard'
|
import { TaskCard } from './TaskCard'
|
||||||
@@ -32,6 +33,10 @@ export function MonitorDashboard() {
|
|||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [editingTask, setEditingTask] = useState<MonitorTask | null>(null)
|
const [editingTask, setEditingTask] = useState<MonitorTask | null>(null)
|
||||||
const [onlyNew, setOnlyNew] = useState(false)
|
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
|
// 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.
|
// reason; keep an explicit selection once the user makes one.
|
||||||
@@ -170,6 +175,7 @@ export function MonitorDashboard() {
|
|||||||
{/* 导出的是**这个任务**的全部作品,不是屏幕上这 200 条 —— 屏幕上那份是
|
{/* 导出的是**这个任务**的全部作品,不是屏幕上这 200 条 —— 屏幕上那份是
|
||||||
为了好看才截断的,导出跟着截断就成了「导出来的比看到的少」。
|
为了好看才截断的,导出跟着截断就成了「导出来的比看到的少」。
|
||||||
走 window.open 而不是 blob:鉴权在 Cookie 上,浏览器自己会带上。 */}
|
走 window.open 而不是 blob:鉴权在 Cookie 上,浏览器自己会带上。 */}
|
||||||
|
<TagFilter selected={tagIds} onChange={setTagIds} />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -185,7 +191,7 @@ export function MonitorDashboard() {
|
|||||||
导出 CSV
|
导出 CSV
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<NotesTable taskId={selectedTask.id} onlyNew={onlyNew} />
|
<NotesTable taskId={selectedTask.id} onlyNew={onlyNew} tagIds={tagIds} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="comments" className="flex-1 overflow-auto terminal-scroll px-3 pb-3 mt-2">
|
<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 { 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 { Badge } from '@/components/ui/badge'
|
||||||
import { useMonitorNotes, useSetCreatorAlias, useSetNoteAlias } from '@/hooks/useMonitor'
|
import { useMonitorNotes, useSetCreatorAlias, useSetNoteAlias } from '@/hooks/useMonitor'
|
||||||
@@ -10,13 +10,18 @@ import {
|
|||||||
formatDelta,
|
formatDelta,
|
||||||
formatRelative,
|
formatRelative,
|
||||||
} from '@/lib/monitorFormat'
|
} from '@/lib/monitorFormat'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { tagColorClass } from '@/lib/tagColors'
|
||||||
import type { MonitorCreator, MonitorNote, NoteMetrics } from '@/types/monitor'
|
import type { MonitorCreator, MonitorNote, NoteMetrics } from '@/types/monitor'
|
||||||
import { NoteCover } from './NoteCover'
|
import { NoteCover } from './NoteCover'
|
||||||
|
import { NoteTagPicker } from './NoteTagPicker'
|
||||||
import { NoteTrendChart } from './NoteTrendChart'
|
import { NoteTrendChart } from './NoteTrendChart'
|
||||||
|
|
||||||
interface NotesTableProps {
|
interface NotesTableProps {
|
||||||
taskId: number | null
|
taskId: number | null
|
||||||
onlyNew: boolean
|
onlyNew: boolean
|
||||||
|
/** 按标签筛。空数组 = 不筛。命中的是**任意一个**选中的标签。 */
|
||||||
|
tagIds?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A metric with its change vs the previous run underneath. */
|
/** A metric with its change vs the previous run underneath. */
|
||||||
@@ -128,8 +133,8 @@ function CreatorStats({ stats }: { stats: AccountStats }) {
|
|||||||
* `creator_hash` —— 爬虫刻意不落原始 user_id,所以这是唯一稳定的创作者标识;
|
* `creator_hash` —— 爬虫刻意不落原始 user_id,所以这是唯一稳定的创作者标识;
|
||||||
* 显示名是已脱敏的昵称(张***三)。两者都认不出来时才退回"未知博主"。
|
* 显示名是已脱敏的昵称(张***三)。两者都认不出来时才退回"未知博主"。
|
||||||
*/
|
*/
|
||||||
export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
|
export function NotesTable({ taskId, onlyNew, tagIds = [] }: NotesTableProps) {
|
||||||
const { data, isLoading } = useMonitorNotes(taskId, onlyNew)
|
const { data, isLoading } = useMonitorNotes(taskId, onlyNew, tagIds)
|
||||||
const notes = data?.notes
|
const notes = data?.notes
|
||||||
const creators = data?.creators
|
const creators = data?.creators
|
||||||
const [expandedNote, setExpandedNote] = useState<string | null>(null)
|
const [expandedNote, setExpandedNote] = useState<string | null>(null)
|
||||||
@@ -147,6 +152,8 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
|
|||||||
const [noteAliasDraft, setNoteAliasDraft] = useState('')
|
const [noteAliasDraft, setNoteAliasDraft] = useState('')
|
||||||
const noteAliasCancelled = useRef(false)
|
const noteAliasCancelled = useRef(false)
|
||||||
const setNoteAlias = useSetNoteAlias()
|
const setNoteAlias = useSetNoteAlias()
|
||||||
|
// 正在贴标签的那条作品。null = 对话框没开。
|
||||||
|
const [taggingNote, setTaggingNote] = useState<MonitorNote | null>(null)
|
||||||
|
|
||||||
const commitAlias = (creatorHash: string) => {
|
const commitAlias = (creatorHash: string) => {
|
||||||
if (aliasCancelled.current) {
|
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">
|
<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">
|
<span className="truncate">
|
||||||
{note.note_id} · {note.snapshot_count} 次快照
|
{note.note_id} · {note.snapshot_count} 次快照
|
||||||
</span>
|
</span>
|
||||||
@@ -470,6 +491,17 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
|
|||||||
<Pencil className="w-2.5 h-2.5" />
|
<Pencil className="w-2.5 h-2.5" />
|
||||||
{note.note_alias ? '改备注' : '备注'}
|
{note.note_alias ? '改备注' : '备注'}
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -514,6 +546,19 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{/* 对话框挂在表格**外面** —— 放在 tbody 里会因为表格的布局规则出问题。 */}
|
||||||
|
{taggingNote && (
|
||||||
|
<NoteTagPicker
|
||||||
|
noteId={taggingNote.note_id}
|
||||||
|
noteTitle={taggingNote.title}
|
||||||
|
current={taggingNote.tags}
|
||||||
|
open={taggingNote !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setTaggingNote(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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 { QrLoginPanel } from '@/components/monitor/QrLoginPanel'
|
||||||
import { RemoteBrowserPanel } from '@/components/settings/RemoteBrowserPanel'
|
import { RemoteBrowserPanel } from '@/components/settings/RemoteBrowserPanel'
|
||||||
import { Section, SettingField } from '@/components/settings/SettingFields'
|
import { Section, SettingField } from '@/components/settings/SettingFields'
|
||||||
|
import { TagSettings } from '@/components/settings/TagSettings'
|
||||||
import { useSettings, useUpdateSettings } from '@/hooks/useMonitor'
|
import { useSettings, useUpdateSettings } from '@/hooks/useMonitor'
|
||||||
import { useCurrentPlatform } from '@/hooks/usePlatform'
|
import { useCurrentPlatform } from '@/hooks/usePlatform'
|
||||||
import { useCrawlerStore } from '@/store/crawlerStore'
|
import { useCrawlerStore } from '@/store/crawlerStore'
|
||||||
@@ -99,6 +100,8 @@ export function SettingsView({ onNavigate }: { onNavigate?: (view: AppView) => v
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<TagSettings />
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
title="采集策略"
|
title="采集策略"
|
||||||
description="各平台风控容忍度不同,因此按平台分别配置。"
|
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`,两者必须来自同一次请求,否则
|
* 最该显示的一类。所以服务端单独给一份 `creators`,两者必须来自同一次请求,否则
|
||||||
* 组头和组里的作品会对不上(比如刚删掉一个任务)。
|
* 组头和组里的作品会对不上(比如刚删掉一个任务)。
|
||||||
*/
|
*/
|
||||||
export function useMonitorNotes(taskId: number | null, onlyNew: boolean) {
|
export function useMonitorNotes(
|
||||||
|
taskId: number | null,
|
||||||
|
onlyNew: boolean,
|
||||||
|
tagIds: number[] = [],
|
||||||
|
) {
|
||||||
const platform = usePlatformParam()
|
const platform = usePlatformParam()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['monitorNotes', platform, taskId, onlyNew],
|
queryKey: ['monitorNotes', platform, taskId, onlyNew, tagIds],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await monitorApi.getNotes(taskId ?? undefined, onlyNew, 200, platform)).data,
|
(await monitorApi.getNotes(taskId ?? undefined, onlyNew, 200, platform, tagIds)).data,
|
||||||
refetchInterval: POLL_MS,
|
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) {
|
export function useNoteSeries(noteId: string | null, taskId: number | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['monitorNoteSeries', noteId, taskId],
|
queryKey: ['monitorNoteSeries', noteId, taskId],
|
||||||
|
|||||||
+27
-2
@@ -10,6 +10,7 @@ import type {
|
|||||||
MonitorNote,
|
MonitorNote,
|
||||||
MonitorOverview,
|
MonitorOverview,
|
||||||
MonitorRun,
|
MonitorRun,
|
||||||
|
MonitorTag,
|
||||||
MonitorTask,
|
MonitorTask,
|
||||||
LoginState,
|
LoginState,
|
||||||
PlatformCapability,
|
PlatformCapability,
|
||||||
@@ -186,9 +187,16 @@ export const monitorApi = {
|
|||||||
getRuns: (id: number, limit = 50) =>
|
getRuns: (id: number, limit = 50) =>
|
||||||
api.get<{ runs: MonitorRun[] }>(`/monitor/tasks/${id}/runs`, { params: { limit } }),
|
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', {
|
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.
|
// Metric time series for one note; the chart reads this.
|
||||||
getNoteSeries: (noteId: string, taskId?: number) =>
|
getNoteSeries: (noteId: string, taskId?: number) =>
|
||||||
@@ -291,6 +299,23 @@ export const monitorApi = {
|
|||||||
{ alias },
|
{ alias },
|
||||||
{ params: { platform } },
|
{ 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 —— 和博主备注是两回事。 */
|
/** 给作品起备注(空串 = 清掉)。键是 note_id —— 和博主备注是两回事。 */
|
||||||
setNoteAlias: (noteId: string, alias: string, platform?: string) =>
|
setNoteAlias: (noteId: string, alias: string, platform?: string) =>
|
||||||
api.put(
|
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: string
|
||||||
|
/**
|
||||||
|
* 贴在这条作品上的标签 —— 从设置里那份**固定词表**选的分类,可以贴好几个。
|
||||||
|
*
|
||||||
|
* 和 `note_alias` 并存、不是一回事:备注是一句话的自由文字,标签是封闭词表里的分类。
|
||||||
|
* 分类得是封闭的,否则「重点 / 重要 / 优先」各写各的,筛选就没法用了。
|
||||||
|
*/
|
||||||
|
tags: TagRef[]
|
||||||
/**
|
/**
|
||||||
* 博主**账号级**指标 —— 作品列表给不了的东西:作品说的是「这条涨了多少赞」,
|
* 博主**账号级**指标 —— 作品列表给不了的东西:作品说的是「这条涨了多少赞」,
|
||||||
* 它说的是「这个人整个账号在涨还是在掉」。
|
* 它说的是「这个人整个账号在涨还是在掉」。
|
||||||
@@ -168,6 +175,21 @@ export interface MonitorCreator {
|
|||||||
last_activity_at: number
|
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 {
|
export interface MetricPoint {
|
||||||
run_id: number
|
run_id: number
|
||||||
captured_at: number
|
captured_at: number
|
||||||
|
|||||||
Reference in New Issue
Block a user