From 973042288f6b9800c7f973e54613abf7e54036d1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 6 Nov 2026 15:09:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(douyu):=20=E5=A2=9E=E5=8A=A0=E5=8D=8F?= =?UTF-8?q?=E4=BD=9C=E5=8D=95=E7=A4=BC=E5=8C=85=E5=AD=97=E5=85=B8=E4=B8=8E?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- tests/test_douyu_collab_orders.py | 134 +++++++ tests/test_migrations.py | 2 +- .../20261106_0066_douyu_collab_gifts.py | 52 +++ web/backend/models.py | 29 ++ web/backend/routers/douyu_collab.py | 76 ++++ web/backend/schemas.py | 27 ++ web/backend/services/douyu_collab_service.py | 169 ++++++++- web/frontend/src/api/douyuCollab.ts | 27 ++ .../src/components/DouyuCollabGiftModal.tsx | 345 ++++++++++++++++++ web/frontend/src/hooks/useCollabRealtime.ts | 2 +- web/frontend/src/pages/DouyuCollabPage.tsx | 165 +++++++-- 12 files changed, 987 insertions(+), 45 deletions(-) create mode 100644 web/backend/migrations/versions/20261106_0066_douyu_collab_gifts.py create mode 100644 web/frontend/src/components/DouyuCollabGiftModal.tsx diff --git a/AGENTS.md b/AGENTS.md index dda671c..611310b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ 所以切分支前先确认「库的迁移版本 == 目标分支的 head」,否则应用会因为 alembic 找不到 revision **直接起不来**(`run_migrations` 没有兜底,会带崩 lifespan)。 - **`main` 与 `dev` 的迁移链已经分叉成两个 head**(共同祖先是 `20261008_0052`): - `main` 走 `20261015_0053` → … → `20261016_0059`,`dev` 走 `20261008_0053`(账号租约)。 + `main` 走 `20261015_0053` → … → `20261106_0066`,`dev` 走 `20261008_0053`(账号租约)。 两个 0053 的 **revision id 不同**,所以 alembic 不会报「重复 revision」, 而是报 **Multiple head revisions are present** —— 合并 `dev` 时必须先补一个 `alembic merge` 节点,否则 `alembic upgrade head` 会直接失败(部署会卡在迁移那一步)。 @@ -120,6 +120,6 @@ docker compose up -d --force-recreate douyu-login | 本地服务 | `./dev.sh`(8800 端口,`--reload`);**启动时会自动跑迁移** | | 本地 MySQL | 容器 `live-hub-py-mysql-1`,端口 3307;库见 §4 | | 生产镜像库 | `douyu_login_prod`(`scripts/refresh_local_prod_mirror.sh` 同步,只读用) | -| 迁移 head | `main` = `20261021_0064`;`dev` = `20261008_0053`(**已经分叉成两个 head**,见 §4) | +| 迁移 head | `main` = `20261106_0066`;`dev` = `20261008_0053`(**已经分叉成两个 head**,见 §4) | | 前端 | `tsc -b` 开了 `noUnusedLocals`,死代码/未用导入直接报 TS6133;镜像构建时会 `npm run build` | | 测试 | `pytest tests/ -q`(用 `douyu_login_test`);`ruff format` + `ruff check` | diff --git a/tests/test_douyu_collab_orders.py b/tests/test_douyu_collab_orders.py index ef85dbe..a470517 100644 --- a/tests/test_douyu_collab_orders.py +++ b/tests/test_douyu_collab_orders.py @@ -127,6 +127,134 @@ class TestDouyuCollabOrders: assert order.status == collab.STATUS_TO_RECHARGE DouyuCollabOrderOut.model_validate(collab.serialize_one(self.session, order)) + def test_manual_gift_without_goods_snapshot_keeps_name_and_points(self): + order = collab.create_order( + self.session, + self.admin, + order_no="DY-MANUAL-001", + shop="店铺A", + account_username="douyu-account-a", + gift_name="套装-暗影哥特", + required_points=888, + home_images=["collab/home-manual.png"], + client=FakeClient(), + ) + assert order.gift_name == "套装-暗影哥特" + assert order.commodity_id == "" + assert order.required_points == 888 + assert order.status == collab.STATUS_TO_RECHARGE + + collab.update_order( + self.session, + self.admin, + order, + { + "gift_name": "套装-暗影哥特", + "commodity_id": "", + "required_points": 888, + "remark": "接口未返回商品,手动填写", + }, + expected_version=order.version, + ) + payload = collab.serialize_one(self.session, order) + assert payload["gift_name"] == "套装-暗影哥特" + assert payload["commodity_id"] == "" + assert payload["required_points"] == 888 + DouyuCollabOrderOut.model_validate(payload) + + def test_gift_catalog_crud_rename_cascades_and_delete_guard(self): + gift = collab.create_gift( + self.session, + self.admin, + name="套装-暗影哥特", + points=888, + remark="接口未返回商品", + ) + assert gift.points == 888 + with pytest.raises(collab.CollabError) as duplicated: + collab.create_gift( + self.session, + self.admin, + name="套装-暗影哥特", + points=900, + ) + assert duplicated.value.code == "gift_exists" + + order = collab.create_order( + self.session, + self.admin, + order_no="DY-GIFT-001", + shop="店铺A", + account_username="douyu-account-a", + gift_name=gift.name, + required_points=None, + home_images=["collab/home-gift.png"], + client=FakeClient(), + ) + assert order.gift_name == "套装-暗影哥特" + assert order.commodity_id == "" + assert order.required_points == 888 + + collab.update_gift( + self.session, + self.admin, + gift, + {"name": "套装-暗影哥特-返场", "points": 999}, + ) + self.session.refresh(order) + assert order.gift_name == "套装-暗影哥特-返场" + assert order.required_points == 888 + + with pytest.raises(collab.CollabError) as in_use: + collab.delete_gift(self.session, self.admin, gift) + assert in_use.value.code == "gift_in_use" + + spare = collab.create_gift( + self.session, + self.admin, + name="套装-星河流浪者", + points=1680, + ) + assert [ + row.name for row in collab.list_gifts(self.session, active_only=True) + ] == [ + "套装-暗影哥特-返场", + "套装-星河流浪者", + ] + collab.delete_gift(self.session, self.admin, spare) + assert [row.name for row in collab.list_gifts(self.session)] == [ + "套装-暗影哥特-返场" + ] + + collab.create_gift( + self.session, + self.admin, + name="礼包A", + points=999, + ) + api_order = collab.create_order( + self.session, + self.admin, + order_no="DY-GIFT-API-001", + shop="店铺A", + account_username="douyu-account-a", + gift_name="礼包A", + required_points=None, + home_images=["collab/home-gift-api.png"], + client=FakeClient(), + ) + # 接口恢复返回商品后以接口积分为准,字典只做兜底。 + assert api_order.required_points == 100 + assert api_order.commodity_id == "gift-a" + + def test_gift_catalog_write_requires_manage_permission(self): + from web.backend.permissions import get_role_permissions + + support = get_role_permissions("support") + assert "douyu:collab:view" in support + assert "douyu:collab:manage" not in support + assert "douyu:collab:manage" in get_role_permissions("operation") + def test_same_account_allows_multiple_orders(self): collab.create_order( self.session, @@ -680,3 +808,9 @@ class TestDouyuCollabOrders: assert "/api/douyu/collab-shops/{shop_id}" in paths assert "put" in paths["/api/douyu/collab-shops/{shop_id}"] assert "delete" in paths["/api/douyu/collab-shops/{shop_id}"] + assert "/api/douyu/collab-gifts" in paths + assert "get" in paths["/api/douyu/collab-gifts"] + assert "post" in paths["/api/douyu/collab-gifts"] + assert "/api/douyu/collab-gifts/{gift_id}" in paths + assert "put" in paths["/api/douyu/collab-gifts/{gift_id}"] + assert "delete" in paths["/api/douyu/collab-gifts/{gift_id}"] diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 0d137dc..65b000d 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261106_0065" +HEAD_REVISION = "20261106_0066" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/web/backend/migrations/versions/20261106_0066_douyu_collab_gifts.py b/web/backend/migrations/versions/20261106_0066_douyu_collab_gifts.py new file mode 100644 index 0000000..fd5e08e --- /dev/null +++ b/web/backend/migrations/versions/20261106_0066_douyu_collab_gifts.py @@ -0,0 +1,52 @@ +"""斗鱼外部单协作的礼包字典。 + +接口暂时不返回礼包信息,新建协作单时需要手填标准礼包名和所需积分。 +这里把「名称 + 积分」做成可维护字典:管理员维护,客服只能选择; +以后接口恢复返回商品时,两边的选项会一起出现在下拉里。 + +订单里仍存礼包名字(`douyu_external_orders.gift_name`),不是 id。 +改名会级联更新历史订单上的礼包名,但改积分**不回填**历史订单, +避免影响已经形成的积分核账结果。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261106_0066" +down_revision: str | None = "20261106_0065" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "douyu_collab_gifts" + + +def upgrade() -> None: + bind = op.get_bind() + if TABLE in set(sa.inspect(bind).get_table_names()): + return + op.create_table( + TABLE, + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("points", sa.Integer(), nullable=False, server_default="0"), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("remark", sa.String(length=255), nullable=False, server_default=""), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("updated_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_index(f"ix_{TABLE}_name", TABLE, ["name"], unique=True) + op.create_index(f"ix_{TABLE}_is_active", TABLE, ["is_active"]) + + +def downgrade() -> None: + bind = op.get_bind() + if TABLE in set(sa.inspect(bind).get_table_names()): + # MySQL 删表会连索引一起删。 + op.drop_table(TABLE) diff --git a/web/backend/models.py b/web/backend/models.py index e96768c..0339e90 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -567,6 +567,35 @@ class DouyuCollabShop(Base): ) +class DouyuCollabGift(Base): + """斗鱼外部单协作的礼包字典(管理员维护名称与所需积分)。""" + + __tablename__ = "douyu_collab_gifts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column( + String(128), unique=True, nullable=False, index=True + ) + points: Mapped[int] = mapped_column( + Integer, default=0, server_default="0", nullable=False + ) + sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="1", index=True + ) + remark: Mapped[str] = mapped_column(String(255), default="", server_default="") + created_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) + updated_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow) + updated_at: Mapped[datetime] = mapped_column( + UTCDateTime, default=_utcnow, onupdate=_utcnow + ) + + class DouyuExternalOrder(Base): """斗鱼外部单协作台账(一行 = 一单 = 一个内部斗鱼账号)。 diff --git a/web/backend/routers/douyu_collab.py b/web/backend/routers/douyu_collab.py index d66d9c5..a066927 100644 --- a/web/backend/routers/douyu_collab.py +++ b/web/backend/routers/douyu_collab.py @@ -29,6 +29,9 @@ from ..schemas import ( DouyuCollabArchiveRequest, DouyuCollabCkExportOut, DouyuCollabCkExportRequest, + DouyuCollabGiftCreate, + DouyuCollabGiftOut, + DouyuCollabGiftUpdate, DouyuCollabGoodsOut, DouyuCollabImagesRequest, DouyuCollabMetricOut, @@ -75,6 +78,21 @@ def _shop_out(shop) -> dict: } +def _gift_out(gift) -> dict: + return { + "id": int(gift.id), + "name": gift.name, + "points": int(gift.points or 0), + "sort_order": int(gift.sort_order or 0), + "is_active": bool(gift.is_active), + "remark": gift.remark or "", + "created_by": gift.created_by, + "updated_by": gift.updated_by, + "created_at": gift.created_at, + "updated_at": gift.updated_at, + } + + def _publish_change(current: User, *, scope: str, action: str) -> None: collab_events.publish(scope=scope, action=action, actor_id=int(current.id)) @@ -693,3 +711,61 @@ def delete_collab_shop( raise _http_error(exc) from exc _publish_change(current, scope="shops", action="shop.delete") return {**result, "id": shop_id} + + +@router.get("/collab-gifts", response_model=list[DouyuCollabGiftOut]) +def list_collab_gifts( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:view")), +): + return [_gift_out(gift) for gift in collab.list_gifts(db)] + + +@router.post("/collab-gifts", response_model=DouyuCollabGiftOut) +def create_collab_gift( + req: DouyuCollabGiftCreate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:manage")), +): + try: + gift = collab.create_gift( + db, + current, + name=req.name, + points=req.points, + remark=req.remark, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="gifts", action="gift.create") + return _gift_out(gift) + + +@router.put("/collab-gifts/{gift_id}", response_model=DouyuCollabGiftOut) +def update_collab_gift( + gift_id: int, + req: DouyuCollabGiftUpdate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:manage")), +): + try: + gift = collab.get_gift(db, gift_id) + collab.update_gift(db, current, gift, req.model_dump(exclude_unset=True)) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="gifts", action="gift.update") + return _gift_out(gift) + + +@router.delete("/collab-gifts/{gift_id}") +def delete_collab_gift( + gift_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:manage")), +): + try: + result = collab.delete_gift(db, current, collab.get_gift(db, gift_id)) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="gifts", action="gift.delete") + return {**result, "id": gift_id} diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 1506bc3..e1b8518 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -1768,6 +1768,33 @@ class DouyuCollabShopOut(BaseModel): updated_at: datetime | None = None +class DouyuCollabGiftCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=128) + points: int = Field(..., ge=0) + remark: str = Field("", max_length=255) + + +class DouyuCollabGiftUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=128) + points: int | None = Field(None, ge=0) + sort_order: int | None = None + is_active: bool | None = None + remark: str | None = Field(None, max_length=255) + + +class DouyuCollabGiftOut(BaseModel): + id: int + name: str + points: int = 0 + sort_order: int = 0 + is_active: bool = True + remark: str = "" + created_by: int | None = None + updated_by: int | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + class DouyuCollabGoodsOut(BaseModel): commodity_id: str = "" name: str = "" diff --git a/web/backend/services/douyu_collab_service.py b/web/backend/services/douyu_collab_service.py index facec7d..0fd282a 100644 --- a/web/backend/services/douyu_collab_service.py +++ b/web/backend/services/douyu_collab_service.py @@ -25,6 +25,7 @@ from core.douyu import DouyuActivityClient from ..models import ( Account, AuditLog, + DouyuCollabGift, DouyuCollabShop, DouyuConfig, DouyuExternalOrder, @@ -474,6 +475,18 @@ def _resolve_gift( cid = _text(row.commodity_id, max_length=64) if points is None: points = _to_int(row.score) + if points is None and name: + manual = ( + db.query(DouyuCollabGift).filter(DouyuCollabGift.name == name).first() + ) + if manual is not None: + points = int(manual.points or 0) + return name, cid, points + if not cid and name: + # 接口暂时不返回商品时,管理员配置的礼包名称和积分仍然可用。 + manual = db.query(DouyuCollabGift).filter(DouyuCollabGift.name == name).first() + if manual is not None: + return name, "", points if points is not None else int(manual.points or 0) return name, cid, points @@ -548,6 +561,7 @@ def create_order( raise CollabError("订单号已存在", status_code=409, code="order_no_exists") images = normalize_images(home_images) + explicit_points = _to_int(required_points) name, cid, points = _resolve_gift( db, gift_name=gift_name, @@ -591,18 +605,23 @@ def create_order( with_goods=True, ) apply_probe_snapshot(order, account, probe) - if points is None and name: + if explicit_points is None and name: live = next( ( item for item in (probe.get("goods") or []) - if str(item.get("commodityName") or "") == name + if str(item.get("name") or item.get("commodityName") or "") + == name ), None, ) if live is not None: - points = _to_int(live.get("score")) - order.required_points = points + live_cid = _text(live.get("commodity_id"), max_length=64) + if live_cid: + order.commodity_id = live_cid + live_points = _to_int(live.get("score")) + if live_points is not None: + order.required_points = live_points elif account is not None: # 草稿不联网探针,但既然用户名已存在,先把内部账号关联上, # 后续补资料时不用再靠“用户名变化”触发解析。 @@ -1400,6 +1419,148 @@ def delete_shop(db: Session, current: User, shop: DouyuCollabShop) -> dict[str, return {"success": True, "name": shop_name} +def list_gifts(db: Session, *, active_only: bool = False) -> list[DouyuCollabGift]: + query = db.query(DouyuCollabGift) + if active_only: + query = query.filter(DouyuCollabGift.is_active.is_(True)) + return query.order_by( + DouyuCollabGift.sort_order.asc(), DouyuCollabGift.id.asc() + ).all() + + +def get_gift(db: Session, gift_id: int) -> DouyuCollabGift: + gift = db.query(DouyuCollabGift).filter(DouyuCollabGift.id == gift_id).first() + if gift is None: + raise CollabError("礼包不存在", status_code=404, code="gift_not_found") + return gift + + +def create_gift( + db: Session, + current: User, + *, + name: str, + points: Any, + remark: str = "", +) -> DouyuCollabGift: + clean = _text(name, max_length=128) + if not clean: + raise CollabError("礼包名不能为空", code="gift_name_required") + clean_points = _to_int(points) + if clean_points is None or clean_points < 0: + raise CollabError("所需积分不能为空且不能小于 0", code="gift_points_invalid") + exists = db.query(DouyuCollabGift).filter(DouyuCollabGift.name == clean).first() + if exists: + raise CollabError("礼包已存在", status_code=409, code="gift_exists") + gift = DouyuCollabGift( + name=clean, + points=clean_points, + remark=_text(remark, max_length=255), + created_by=current.id, + updated_by=current.id, + ) + db.add(gift) + db.commit() + db.refresh(gift) + return gift + + +def update_gift( + db: Session, + current: User, + gift: DouyuCollabGift, + payload: Mapping[str, Any], +) -> DouyuCollabGift: + old_name = gift.name + old = { + "name": gift.name, + "points": gift.points, + "sort_order": gift.sort_order, + "is_active": gift.is_active, + "remark": gift.remark, + } + if "name" in payload: + name = _text(payload.get("name"), max_length=128) + if not name: + raise CollabError("礼包名不能为空", code="gift_name_required") + duplicate = ( + db.query(DouyuCollabGift.id) + .filter( + DouyuCollabGift.name == name, + DouyuCollabGift.id != gift.id, + ) + .first() + ) + if duplicate: + raise CollabError("礼包已存在", status_code=409, code="gift_exists") + gift.name = name + if "points" in payload: + points = _to_int(payload.get("points")) + if points is None or points < 0: + raise CollabError( + "所需积分不能为空且不能小于 0", code="gift_points_invalid" + ) + gift.points = points + if "sort_order" in payload: + gift.sort_order = _to_int(payload.get("sort_order")) or 0 + if "is_active" in payload: + gift.is_active = bool(payload.get("is_active")) + if "remark" in payload: + gift.remark = _text(payload.get("remark"), max_length=255) + gift.updated_by = current.id + if gift.name != old_name: + # 名称是订单里的展示/匹配口径,改名要同步历史订单; + # 积分是下单时的快照,更新字典不回填历史订单。 + db.query(DouyuExternalOrder).filter( + DouyuExternalOrder.gift_name == old_name + ).update({DouyuExternalOrder.gift_name: gift.name}, synchronize_session=False) + record_audit( + db, + current, + action="douyu:collab:gift_update", + target=f"douyu_collab_gift:{gift.id}", + detail={ + "old": old, + "new": { + "name": gift.name, + "points": gift.points, + "sort_order": gift.sort_order, + "is_active": gift.is_active, + "remark": gift.remark, + }, + }, + ) + db.commit() + db.refresh(gift) + return gift + + +def delete_gift(db: Session, current: User, gift: DouyuCollabGift) -> dict[str, Any]: + gift_name = gift.name + gift_points = gift.points + used = ( + db.query(func.count(DouyuExternalOrder.id)) + .filter(DouyuExternalOrder.gift_name == gift_name) + .scalar() + or 0 + ) + if used: + raise CollabError( + f"还有 {used} 个协作单使用「{gift_name}」,请先停用或改名", + code="gift_in_use", + ) + record_audit( + db, + current, + action="douyu:collab:gift_delete", + target=f"douyu_collab_gift:{gift.id}", + detail={"name": gift_name, "points": gift_points}, + ) + db.delete(gift) + db.commit() + return {"success": True, "name": gift_name} + + def list_gift_names(db: Session) -> list[str]: rows = ( db.query(DouyuExternalOrder.gift_name) diff --git a/web/frontend/src/api/douyuCollab.ts b/web/frontend/src/api/douyuCollab.ts index e94feae..aa6399a 100644 --- a/web/frontend/src/api/douyuCollab.ts +++ b/web/frontend/src/api/douyuCollab.ts @@ -175,6 +175,15 @@ export interface DouyuCollabShop { remark: string; } +export interface DouyuCollabGift { + id: number; + name: string; + points: number; + sort_order: number; + is_active: boolean; + remark: string; +} + export interface DouyuCollabAccountLookup { found: boolean; ok: boolean; @@ -300,6 +309,24 @@ export const douyuCollabApi = { api.delete<{ success: boolean; name: string; id: number }, { success: boolean; name: string; id: number }>( `/douyu/collab-shops/${id}`, ), + giftRecords: () => + api.get('/douyu/collab-gifts'), + createGift: (data: { name: string; points: number; remark?: string }) => + api.post('/douyu/collab-gifts', data), + updateGift: ( + id: number, + data: { + name?: string; + points?: number; + sort_order?: number; + is_active?: boolean; + remark?: string; + }, + ) => api.put(`/douyu/collab-gifts/${id}`, data), + deleteGift: (id: number) => + api.delete<{ success: boolean; name: string; id: number }, { success: boolean; name: string; id: number }>( + `/douyu/collab-gifts/${id}`, + ), gifts: () => api.get('/douyu/external-orders/gifts'), goods: () => api.get('/douyu/external-orders/goods'), refreshGoods: (accountId?: number) => diff --git a/web/frontend/src/components/DouyuCollabGiftModal.tsx b/web/frontend/src/components/DouyuCollabGiftModal.tsx new file mode 100644 index 0000000..1d903d3 --- /dev/null +++ b/web/frontend/src/components/DouyuCollabGiftModal.tsx @@ -0,0 +1,345 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Col, + Empty, + Input, + InputNumber, + Modal, + Popconfirm, + Row, + Space, + Switch, + Table, + Tag, + Tooltip, + Typography, +} from 'antd'; +import type { TableProps } from 'antd'; +import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'; +import { douyuCollabApi } from '../api/modules'; +import type { DouyuCollabGift } from '../api/douyuCollab'; +import { message } from '../utils/antdMessage'; +import { getErrorMessage } from '../utils/error'; + +const { Text } = Typography; + +/** + * 斗鱼协作礼包字典:管理员维护标准礼包名和所需积分,客服只能在下拉里选择。 + * + * - 停用后不再出现在新建下拉里,历史订单不受影响; + * - 改名会同步已有订单上的礼包名; + * - 改积分只影响之后新建的单,不回填历史订单(避免改变已完成的积分核账); + * - 删除只删字典行,仍有订单使用时后端会拒绝。 + */ +export default function DouyuCollabGiftModal({ + open, + gifts, + canManage, + onClose, + onChanged, +}: { + open: boolean; + gifts: DouyuCollabGift[]; + canManage: boolean; + onClose: () => void; + onChanged: () => void; +}) { + const [newName, setNewName] = useState(''); + const [newPoints, setNewPoints] = useState(null); + const [busy, setBusy] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [editName, setEditName] = useState(''); + const [editPoints, setEditPoints] = useState(null); + const [editRemark, setEditRemark] = useState(''); + + useEffect(() => { + if (!open) { + setNewName(''); + setNewPoints(null); + setEditTarget(null); + } + }, [open]); + + const create = async () => { + const name = newName.trim(); + if (!name) { + message.warning('请先输入礼包名'); + return; + } + if (newPoints === null) { + message.warning('请填写礼包所需积分'); + return; + } + setBusy(true); + try { + await douyuCollabApi.createGift({ name, points: newPoints }); + message.success(`已添加礼包「${name}」`); + setNewName(''); + setNewPoints(null); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBusy(false); + } + }; + + const patch = async ( + gift: DouyuCollabGift, + payload: { sort_order?: number; is_active?: boolean; remark?: string }, + ) => { + setBusy(true); + try { + await douyuCollabApi.updateGift(gift.id, payload); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + onChanged(); + } finally { + setBusy(false); + } + }; + + const openEdit = (gift: DouyuCollabGift) => { + setEditTarget(gift); + setEditName(gift.name); + setEditPoints(gift.points); + setEditRemark(gift.remark || ''); + }; + + const submitEdit = async () => { + if (!editTarget) return; + const name = editName.trim(); + if (!name) { + message.warning('礼包名不能为空'); + return; + } + if (editPoints === null) { + message.warning('请填写礼包所需积分'); + return; + } + setBusy(true); + try { + await douyuCollabApi.updateGift(editTarget.id, { + name, + points: editPoints, + remark: editRemark.trim(), + }); + message.success(`已保存礼包「${name}」`); + setEditTarget(null); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBusy(false); + } + }; + + const remove = async (gift: DouyuCollabGift) => { + setBusy(true); + try { + await douyuCollabApi.deleteGift(gift.id); + message.success(`已删除礼包「${gift.name}」`); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBusy(false); + } + }; + + const columns: TableProps['columns'] = [ + { + title: '礼包名', + dataIndex: 'name', + width: 260, + render: (value: string, row) => ( + + {value} + {!row.is_active && 已停用} + {canManage && ( + + } + width={900} + destroyOnHidden + > + + + 先在这里维护标准礼包名和所需积分,新建协作单时直接选择。礼包名请按兑换记录里的名称填写; + 停用只是不再出现在新建选择里,历史订单不受影响;改名会同步已有订单,改积分只影响之后新建的单。 + + {canManage && ( + + + setNewName(event.target.value)} + onPressEnter={() => void create()} + /> + + + setNewPoints(value ?? null)} + /> + + + + + + )} + + rowKey="id" + size="small" + columns={columns} + dataSource={gifts} + pagination={false} + scroll={{ y: 340 }} + locale={{ + emptyText: , + }} + /> + + + + setEditTarget(null)} + onOk={() => void submitEdit()} + confirmLoading={busy} + okText="保存" + destroyOnHidden + > + + setEditName(event.target.value)} + onPressEnter={() => void submitEdit()} + /> + setEditPoints(value ?? null)} + /> + setEditRemark(event.target.value)} + /> + + 改名会同步已有协作单上的礼包名;修改积分只影响之后新建的协作单,不会改变历史订单。 + + + + + ); +} diff --git a/web/frontend/src/hooks/useCollabRealtime.ts b/web/frontend/src/hooks/useCollabRealtime.ts index d4a9306..e99d17a 100644 --- a/web/frontend/src/hooks/useCollabRealtime.ts +++ b/web/frontend/src/hooks/useCollabRealtime.ts @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; export interface CollabRealtimeEvent { type: 'collab.ready' | 'collab.ping' | 'collab.invalidate'; revision: number; - scope?: 'orders' | 'shops' | 'all'; + scope?: 'orders' | 'shops' | 'gifts' | 'all'; action?: string; actor_id?: number | null; at?: string; diff --git a/web/frontend/src/pages/DouyuCollabPage.tsx b/web/frontend/src/pages/DouyuCollabPage.tsx index 5cbc79c..0a81922 100644 --- a/web/frontend/src/pages/DouyuCollabPage.tsx +++ b/web/frontend/src/pages/DouyuCollabPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Alert, App, + AutoComplete, Badge, Button, Card, @@ -34,6 +35,7 @@ import { DeleteOutlined, EditOutlined, FormOutlined, + GiftOutlined, InboxOutlined, LinkOutlined, PlusOutlined, @@ -47,12 +49,14 @@ import { UserOutlined, } from '@ant-design/icons'; import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader'; +import DouyuCollabGiftModal from '../components/DouyuCollabGiftModal'; import DouyuCollabShopModal from '../components/DouyuCollabShopModal'; import DouyuCollabShopPicker from '../components/DouyuCollabShopPicker'; import { douyuCollabApi } from '../api/modules'; import type { DouyuCollabAccountLookup, DouyuCollabCkExportResult, + DouyuCollabGift, DouyuCollabGoods, DouyuCollabMetric, DouyuCollabOrder, @@ -213,6 +217,7 @@ export default function DouyuCollabPage() { }); const [owners, setOwners] = useState>([]); const [shopRecords, setShopRecords] = useState([]); + const [giftRecords, setGiftRecords] = useState([]); const [gifts, setGifts] = useState([]); const [goods, setGoods] = useState([]); const [loading, setLoading] = useState(false); @@ -233,6 +238,7 @@ export default function DouyuCollabPage() { const [archiveScope, setArchiveScope] = useState<'active' | 'archived' | 'all'>('active'); const [includeVoid, setIncludeVoid] = useState(false); const [shopModalOpen, setShopModalOpen] = useState(false); + const [giftModalOpen, setGiftModalOpen] = useState(false); const [realtimePending, setRealtimePending] = useState(false); const [realtimePaused, setRealtimePaused] = useState(readRealtimePaused); @@ -323,15 +329,17 @@ export default function DouyuCollabPage() { const loadMeta = useCallback(async () => { try { - const [ownerRows, giftRows, goodsRows, records] = await Promise.all([ + const [ownerRows, giftRows, goodsRows, giftRecordRows, records] = await Promise.all([ douyuCollabApi.owners(), douyuCollabApi.gifts(), douyuCollabApi.goods(), + douyuCollabApi.giftRecords(), douyuCollabApi.shopRecords(), ]); setOwners(ownerRows); setGifts(giftRows); setGoods(goodsRows); + setGiftRecords(giftRecordRows); setShopRecords(records); } catch (error) { message.error(getErrorMessage(error)); @@ -354,6 +362,7 @@ export default function DouyuCollabPage() { createOpen || detailOpen || shopModalOpen || + giftModalOpen || batchStatusOpen || statusTarget || ckExportOpen || @@ -561,16 +570,40 @@ export default function DouyuCollabPage() { return () => window.clearTimeout(timer); }, [createOpen, form.account_username, lookupUsername]); - const handleGiftChange = (commodityId: string) => { + const handleGiftNameChange = (value: string) => { + const name = value.trim(); const pool = lookup?.goods?.length ? lookup.goods : goods; - const item = pool.find((row) => row.commodity_id === commodityId); - if (!item) return; - setForm((previous) => ({ - ...previous, - commodity_id: item.commodity_id, - gift_name: item.name, - required_points: item.score, - })); + const catalog = giftRecords.find((row) => row.name === name); + const item = pool.find((row) => row.name === name); + setForm((previous) => { + if (!name) { + return { ...previous, gift_name: '', commodity_id: '', required_points: null }; + } + if (item) { + return { + ...previous, + gift_name: item.name, + commodity_id: item.commodity_id, + required_points: item.score ?? catalog?.points ?? null, + }; + } + if (catalog) { + return { + ...previous, + gift_name: catalog.name, + commodity_id: '', + required_points: catalog.points, + }; + } + // 手动填写新礼包:清掉接口商品 ID;如果是从接口选项改成手填,积分也一并清掉, + // 避免把上一个礼包的积分带过来。已经手填时保留积分,方便继续改数字。 + return { + ...previous, + gift_name: name, + commodity_id: '', + required_points: previous.commodity_id ? null : previous.required_points, + }; + }); }; const submitCreate = async (draft: boolean) => { @@ -1328,21 +1361,43 @@ export default function DouyuCollabPage() { ]; const goodsPool = lookup?.goods?.length ? lookup.goods : goods; - const giftOptions = goodsOptions(goodsPool, Boolean(lookup?.goods?.length)).map((item) => ({ - value: item.value, - label: item.label, - disabled: item.disabled, - })); - if ( - form.commodity_id && - !giftOptions.some((item) => item.value === form.commodity_id) - ) { - giftOptions.unshift({ - value: form.commodity_id, - label: `${form.gift_name || form.commodity_id} · 当前已选`, - disabled: false, - }); - } + const giftNameOptions = (() => { + const options: Array<{ value: string; label: string; disabled: boolean }> = []; + const seen = new Set(); + for (const item of goodsOptions(goodsPool, Boolean(lookup?.goods?.length))) { + if (!item.goods.name || seen.has(item.goods.name)) continue; + seen.add(item.goods.name); + options.push({ + value: item.goods.name, + label: item.label, + disabled: item.disabled, + }); + } + for (const gift of giftRecords) { + if (!gift.is_active || !gift.name || seen.has(gift.name)) continue; + seen.add(gift.name); + options.push({ + value: gift.name, + label: `${gift.name} · ${gift.points} 积分 · 已配置`, + disabled: false, + }); + } + if (canManage) { + for (const name of gifts) { + if (!name || seen.has(name)) continue; + seen.add(name); + options.push({ value: name, label: `${name} · 历史手填`, disabled: false }); + } + } + if (!canManage && form.gift_name && !seen.has(form.gift_name)) { + options.unshift({ + value: form.gift_name, + label: `${form.gift_name} · 当前已选`, + disabled: false, + }); + } + return options; + })(); return ( @@ -1637,6 +1692,11 @@ export default function DouyuCollabPage() { 刷新活动商品 )} + {canManage && ( + + )} {canEdit && (