21 changed files with 2402 additions and 95 deletions
+2 -2
View File
@@ -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` |
+24
View File
@@ -1051,6 +1051,30 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
2. **不要让人工直写 `recharged` / `points_error`**:这两个状态必须由核账产生,否则积分快照与状态会各说各话。
3. **换 CK 不等于资料缺失**:`set_ck()` 只重置 `ck_status`;核账会强制重验,若绑定账号/角色变了整组报错,不会误落旧组。
### 11.13 2026-11-07 第十轮:礼包名称字典与历史改名级联
需求原文:"斗鱼的外部协作单增加了一个礼包名称编辑功能,虎牙也需要这个功能。"
虎牙原来只有「实时活动礼包缓存」和台账历史名:活动接口拉不到、旧礼包下线或名称录错时,
没有管理员可维护的标准口径。现在新增与斗鱼同级的礼包字典,同时保留实时礼包这条旧路径。
| 面 | 落地 |
| --- | --- |
| 数据模型 | 迁移 `20261107_0067`:`huya_collab_gifts`(名称唯一 / 所需积分 / 排序 / 启停 / 备注 / 操作人)。订单仍存礼包名字符串,不改成外键 |
| 后端 | `GET/POST /huya/collab-gifts`、`PUT/DELETE /huya/collab-gifts/{gift_id}`;查看走 `huya:collab:view`,写操作走 `huya:collab:manage` |
| 改名 | 字典改名会级联所有历史订单的 `gift_name`(含归档行),避免筛选里同时出现新旧两个名字;审计记录字段 diff 与实际同步行数 |
| 积分边界 | 改字典积分**不回填**历史订单的 `required_points` 快照;只影响之后新建或换礼包时自动带出的值 |
| 删除 | 仍有订单使用该礼包名时拒绝删除并提示改用停用;未被引用的字典行可删 |
| 前端 | 工具栏新增「礼包管理」;新建、补资料、详情和表格内联选择统一合并「实时活动礼包 + 启用的标准字典」,实时接口为空时仍可选字典项 |
| 保留旧路径 | 实时礼包缓存、强制刷新礼包、常用礼包按钮和现有订单历史值都保留;实时价格优先,缺失时回落到字典积分 |
**四条边界**:
1. **字典是标准口径,不是库存快照**:活动接口返回的实时价格 / 库存仍以接口为准,字典只补名称与积分兜底。
2. **改名必须级联**:订单里存的是名称字符串;只改字典会让同一礼包在筛选和历史单里分成两个值。
3. **积分不追改历史**:`required_points` 是下单时的核账快照,改字典不能改变已经完成的充值核账依据。
4. **删不掉的礼包用停用**:停用只影响新选择,不隐藏历史订单,也不会让旧数据失去可读名称。
---
## 12. 风险
+250
View File
@@ -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,
@@ -189,6 +317,122 @@ class TestDouyuCollabOrders:
assert result["order"].status == collab.STATUS_DONE
assert result["order"].done_at is not None
def test_admin_can_restore_voided_order_to_another_status(self):
order = collab.create_order(
self.session,
self.admin,
order_no="DY-RESTORE-1",
shop="店铺A",
account_username="douyu-account-a",
gift_name="礼包A",
required_points=100,
home_images=["collab/home-restore-1.png"],
client=FakeClient(),
)
collab.set_status(
self.session,
self.admin,
order,
status=collab.STATUS_VOID,
reason="误作废",
)
restored = collab.set_status(
self.session,
self.admin,
order,
status=collab.STATUS_SCRIPTED,
reason="管理员确认恢复",
)
assert restored.status == collab.STATUS_SCRIPTED
assert restored.void_reason == ""
assert restored.recharged_at is not None
assert restored.scripted_at is not None
payload = collab.serialize_one(self.session, restored)
serialized = DouyuCollabOrderOut.model_validate(payload).model_dump()
assert serialized["status"] == collab.STATUS_SCRIPTED
assert serialized["void_reason"] == ""
audit = (
self.session.query(AuditLog)
.filter(AuditLog.action == "douyu:collab:void_restore")
.order_by(AuditLog.id.desc())
.first()
)
assert audit is not None
assert audit.username == self.admin.username
assert '"from":"void"' in audit.detail
assert '"to":"scripted"' in audit.detail
assert '"previous_void_reason":"误作废"' in audit.detail
def test_non_admin_cannot_restore_voided_order(self):
operator = User(
username="douyu-collab-restore-operator",
password_hash="hash",
role="operation",
is_active=True,
)
self.session.add(operator)
self.session.commit()
order = collab.create_order(
self.session,
self.admin,
order_no="DY-RESTORE-2",
account_username="douyu-account-a",
draft=True,
)
collab.set_status(
self.session,
self.admin,
order,
status=collab.STATUS_VOID,
reason="测试权限",
)
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(
self.session,
operator,
order,
status=collab.STATUS_INCOMPLETE,
reason="越权恢复",
)
assert excinfo.value.status_code == 403
assert excinfo.value.code == "admin_required"
assert order.status == collab.STATUS_VOID
assert order.void_reason == "测试权限"
def test_void_restore_still_enforces_recharge_fields(self):
order = collab.create_order(
self.session,
self.admin,
order_no="DY-RESTORE-3",
account_username="douyu-account-a",
draft=True,
)
collab.set_status(
self.session,
self.admin,
order,
status=collab.STATUS_VOID,
reason="资料不全",
)
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(
self.session,
self.admin,
order,
status=collab.STATUS_RECHARGED,
reason="直接恢复",
)
assert excinfo.value.code == "recharge_precondition"
assert order.status == collab.STATUS_VOID
assert order.void_reason == "资料不全"
def test_points_shortage_enters_points_error(self):
order = collab.create_order(
self.session,
@@ -680,3 +924,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}"]
+185
View File
@@ -0,0 +1,185 @@
"""虎牙协作礼包字典:CRUD、改名级联、积分快照边界与权限。"""
from __future__ import annotations
import json
import pytest
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import AuditLog, HuyaCollabGiftCatalog, HuyaExternalOrder, User
from web.backend.permissions import ROLE_PERMISSIONS
from web.backend.routers.huya import collab as collab_api
from web.backend.schemas import HuyaCollabGiftCatalogOut
from web.backend.services import huya_collab_service as collab
class TestHuyaCollabGiftCatalog:
def setup_method(self):
self.engine = get_test_engine()
self.db = make_test_session(self.engine)
self.user = User(username="gift_op", password_hash="h", role="operation")
self.support = User(username="gift_support", password_hash="h", role="support")
self.db.add_all([self.user, self.support])
self.db.commit()
def teardown_method(self):
self.db.close()
def _gift(self, name: str, points: int = 8000, **kwargs) -> HuyaCollabGiftCatalog:
return collab.create_gift(
self.db, self.user, name=name, points=points, **kwargs
)
def _order(self, order_no: str, gift_name: str = "", **kwargs) -> HuyaExternalOrder:
return collab.create_order(
self.db,
self.user,
order_no=order_no,
gift_name=gift_name,
**kwargs,
)
def _audits(self, gift: HuyaCollabGiftCatalog) -> list[AuditLog]:
return (
self.db.query(AuditLog)
.filter(AuditLog.target == f"huya_collab_gift:{gift.id}")
.order_by(AuditLog.id.asc())
.all()
)
def test_create_list_and_serialize(self):
self._gift("乙礼包", points=900, sort_order=2)
self._gift("甲礼包", points=800, sort_order=1)
rows = collab.list_gift_records(self.db)
assert [row.name for row in rows] == ["甲礼包", "乙礼包"]
dumped = HuyaCollabGiftCatalogOut.model_validate(
collab.serialize_gift(
rows[0], users={int(self.user.id): self.user.username}
)
).model_dump()
assert dumped["name"] == "甲礼包"
assert dumped["points"] == 800
assert dumped["is_active"] is True
assert dumped["created_by_name"] == self.user.username
assert [row.action for row in self._audits(rows[0])] == [
"huya:collab:gift_create"
]
def test_create_rejects_duplicate_blank_and_invalid_points(self):
self._gift("甲礼包")
with pytest.raises(collab.CollabError) as duplicate:
self._gift(" 甲礼包 ")
assert duplicate.value.status_code == 409
assert duplicate.value.code == "gift_name_conflict"
with pytest.raises(collab.CollabError) as blank:
self._gift(" ")
assert blank.value.code == "gift_name_required"
with pytest.raises(collab.CollabError) as invalid:
self._gift("乙礼包", points="not-a-number")
assert invalid.value.code == "required_points_invalid"
def test_create_order_uses_catalog_points_when_points_are_omitted(self):
gift = self._gift("标准礼包", points=4321)
order = self._order("GIFT-POINTS-1", gift_name=gift.name)
assert order.required_points == 4321
def test_changing_order_gift_name_uses_new_catalog_points(self):
first = self._gift("礼包A", points=200)
second = self._gift("礼包B", points=300)
order = self._order("GIFT-POINTS-2", gift_name=first.name)
changes = collab.update_order(
self.db, self.user, order, {"gift_name": second.name}
)
assert changes["required_points"] == {"old": 200, "new": 300}
assert order.required_points == 300
def test_rename_cascades_but_points_change_does_not_backfill_orders(self):
gift = self._gift("旧礼包名", points=8000)
order = self._order("GIFT-RENAME-1", gift_name=gift.name)
changes = collab.update_gift(
self.db, self.user, gift, {"name": "新礼包名", "points": 9000}
)
self.db.refresh(order)
assert changes["name"] == {"old": "旧礼包名", "new": "新礼包名"}
assert changes["points"] == {"old": 8000, "new": 9000}
assert order.gift_name == "新礼包名"
assert order.required_points == 8000
audit = self._audits(gift)[-1]
assert json.loads(audit.detail)["renamed_orders"] == 1
def test_delete_is_blocked_while_orders_use_the_gift(self):
gift = self._gift("在用的礼包")
self._order("GIFT-DELETE-1", gift_name=gift.name)
with pytest.raises(collab.CollabError) as excinfo:
collab.delete_gift(self.db, self.user, gift)
assert excinfo.value.code == "gift_in_use"
assert self.db.query(HuyaCollabGiftCatalog).count() == 1
def test_unused_gift_can_be_deleted_with_audit(self):
gift = self._gift("可删礼包")
snapshot = collab.delete_gift(self.db, self.user, gift)
assert snapshot["name"] == "可删礼包"
assert self.db.query(HuyaCollabGiftCatalog).count() == 0
audit = (
self.db.query(AuditLog)
.filter(AuditLog.action == "huya:collab:gift_delete")
.one()
)
assert "可删礼包" in audit.detail
def test_inactive_gift_stays_in_catalog_for_management(self):
gift = self._gift("准备停用")
collab.update_gift(self.db, self.user, gift, {"is_active": False})
rows = collab.list_gift_records(self.db)
assert len(rows) == 1
assert rows[0].is_active is False
def test_write_permission_is_operation_only_by_default(self):
assert "huya:collab:manage" in ROLE_PERMISSIONS["operation"]
assert "huya:collab:manage" not in ROLE_PERMISSIONS["support"]
assert "huya:collab:manage" in ROLE_PERMISSIONS["super_admin"]
def test_routes_are_registered(self):
from web.backend.main import app
paths = app.openapi()["paths"]
assert "/api/huya/collab-gifts" in paths
assert "get" in paths["/api/huya/collab-gifts"]
assert "post" in paths["/api/huya/collab-gifts"]
assert "/api/huya/collab-gifts/{gift_id}" in paths
assert "put" in paths["/api/huya/collab-gifts/{gift_id}"]
assert "delete" in paths["/api/huya/collab-gifts/{gift_id}"]
def test_router_response_keeps_all_catalog_fields(self):
gift = self._gift("接口礼包", points=6600, remark="按兑换记录标准名")
payload = collab_api.create_collab_gift(
req=collab_api.HuyaCollabGiftCatalogCreate(name="另一个礼包", points=7700),
db=self.db,
current=self.user,
)
assert payload["name"] == "另一个礼包"
assert payload["points"] == 7700
assert payload["created_by_name"] == self.user.username
listed = collab_api.list_collab_gifts(db=self.db, _current=self.user)
by_name = {item["name"]: item for item in listed}
assert by_name[gift.name]["remark"] == "按兑换记录标准名"
+1 -1
View File
@@ -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 = "20261107_0067"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
@@ -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)
@@ -0,0 +1,51 @@
"""虎牙外部单协作的礼包字典。
虎牙实时礼包接口可能拿不到活动商品,历史礼包名也会随活动下线。这里维护一份
管理员可控的标准名称与积分:新建 / 补资料时可直接选择。
订单里仍存礼包名字符串(``huya_external_orders.gift_name``),不是 id。
改名会级联更新历史订单上的礼包名;改积分只影响之后新建或换礼包时的默认值,
不回填历史订单的 ``required_points`` 快照。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261107_0067"
down_revision: str | None = "20261106_0066"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE = "huya_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)
+62
View File
@@ -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):
"""斗鱼外部单协作台账(一行 = 一单 = 一个内部斗鱼账号)。
@@ -1394,6 +1423,39 @@ class HuyaCollabShop(Base):
)
class HuyaCollabGiftCatalog(Base):
"""虎牙外部单协作的礼包字典(管理员维护标准名称与所需积分)。
订单里仍存礼包名字符串;改名会级联历史订单,积分只作为之后新建/换礼包时的
默认值,不回填历史订单的 ``required_points`` 快照。
"""
__tablename__ = "huya_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 HuyaExternalOrder(Base):
"""虎牙外部 CK 协作台账(替代 Excel 多人协同表)。
+76
View File
@@ -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}
+82
View File
@@ -37,6 +37,9 @@ from ...schemas import (
HuyaCollabCkPreviewOut,
HuyaCollabCkPreviewRequest,
HuyaCollabFrequentGiftOut,
HuyaCollabGiftCatalogCreate,
HuyaCollabGiftCatalogOut,
HuyaCollabGiftCatalogUpdate,
HuyaCollabMetricOut,
HuyaCollabShopCreate,
HuyaCollabShopOut,
@@ -97,6 +100,11 @@ def _publish_change(current: User, *, scope: str, action: str) -> None:
collab_events.publish(scope=scope, action=action, actor_id=int(current.id))
def _gift_out(db: Session, gift) -> dict:
names = collab.user_names(db, [gift.created_by, gift.updated_by])
return collab.serialize_gift(gift, users=names)
# --------------------------------------------------------------------------
# 字面量路径必须声明在 /{order_id} 之前,否则 "summary" 会被当成 id
# --------------------------------------------------------------------------
@@ -239,6 +247,80 @@ def delete_collab_shop(
return {"success": True, "name": snapshot["name"]}
@router.get("/collab-gifts", response_model=list[HuyaCollabGiftCatalogOut])
def list_collab_gifts(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""全部礼包字典项(含停用的:管理弹窗要看到;新建时前端按 is_active 过滤)。"""
gifts = collab.list_gift_records(db)
names = collab.user_names(
db,
[gift.created_by for gift in gifts] + [gift.updated_by for gift in gifts],
)
return [collab.serialize_gift(gift, users=names) for gift in gifts]
@router.post("/collab-gifts", response_model=HuyaCollabGiftCatalogOut)
def create_collab_gift(
req: HuyaCollabGiftCatalogCreate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:manage")),
):
"""新增标准礼包(重名 409;写操作仅管理员)。"""
try:
gift = collab.create_gift(
db,
current,
name=req.name,
points=req.points,
sort_order=req.sort_order,
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(db, gift)
@router.put("/collab-gifts/{gift_id}", response_model=HuyaCollabGiftCatalogOut)
def update_collab_gift(
gift_id: int,
req: HuyaCollabGiftCatalogUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya: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="all", action="gift.update")
return _gift_out(db, gift)
@router.delete("/collab-gifts/{gift_id}")
def delete_collab_gift(
gift_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:manage")),
):
"""删除未被历史订单引用的字典项;仍在使用时改为停用。"""
try:
gift = collab.get_gift(db, gift_id)
result = collab.delete_gift(db, current, gift)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="gifts", action="gift.delete")
return {
"success": True,
"id": gift_id,
"name": result["name"],
"points": result["points"],
}
@router.get("/external-orders", response_model=HuyaExternalOrderListOut)
def list_external_orders(
shop: str = Query("", max_length=64),
+61
View File
@@ -1184,6 +1184,40 @@ class HuyaCollabShopOut(BaseModel):
updated_at: datetime | None = None
class HuyaCollabGiftCatalogCreate(BaseModel):
"""新增虎牙协作礼包字典项。"""
name: str = Field(..., min_length=1, max_length=128)
points: int = Field(..., ge=0)
sort_order: int = 0
remark: str = Field("", max_length=255)
class HuyaCollabGiftCatalogUpdate(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 HuyaCollabGiftCatalogOut(BaseModel):
"""礼包字典项(与实时活动礼包 ``HuyaCollabGiftOut`` 分开)。"""
id: int
name: str
points: int = 0
sort_order: int = 0
is_active: bool = True
remark: str = ""
created_by_name: str = ""
updated_by_name: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
# ---- 虎牙外部 CK 协作台账(见 docs/设计-虎牙外部CK协作台账.md) ----
class HuyaCollabGiftOut(BaseModel):
"""活动礼包(用一份有效 CK 现拉,不落库)。"""
@@ -1768,6 +1802,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 = ""
+229 -5
View File
@@ -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:
# 草稿不联网探针,但既然用户名已存在,先把内部账号关联上,
# 后续补资料时不用再靠“用户名变化”触发解析。
@@ -889,6 +908,60 @@ def _assert_ready_for_recharge(order: DouyuExternalOrder) -> None:
)
def _restore_void_status(
db: Session,
current: User,
order: DouyuExternalOrder,
*,
target: str,
reason: str,
commit: bool,
) -> DouyuExternalOrder:
"""超级管理员取消作废,并把订单转为指定状态。"""
if current.role != "super_admin":
raise CollabError(
"仅超级管理员可取消作废",
status_code=403,
code="admin_required",
)
if order.archived_at is not None:
raise CollabError("已归档的订单不能改状态", code="order_archived")
target_index = STAGE_INDEX.get(target)
if target_index is not None and target_index >= STAGE_INDEX[STATUS_TO_RECHARGE]:
_assert_ready_for_recharge(order)
old_void_reason = order.void_reason or ""
# 取消作废等于重新进入状态机:先清空旧流程时间,再按目标状态补当前时间。
for field in STATUS_TIMESTAMP_FIELDS.values():
setattr(order, field, None)
if target_index is not None:
for name, field in STATUS_TIMESTAMP_FIELDS.items():
if STAGE_INDEX[name] <= target_index:
setattr(order, field, _now())
order.status = target
order.void_reason = ""
order.updated_by = current.id
order.version = int(order.version or 0) + 1
record_audit(
db,
current,
action="douyu:collab:void_restore",
target=_status_audit_target(order),
detail={
"from": STATUS_VOID,
"to": target,
"previous_void_reason": old_void_reason,
"reason": _text(reason, max_length=256),
},
)
if commit:
db.commit()
db.refresh(order)
return order
def set_status(
db: Session,
current: User,
@@ -907,7 +980,16 @@ def set_status(
if order.archived_at is not None:
raise CollabError("已归档的订单不能改状态", code="order_archived")
if order.status == STATUS_VOID:
raise CollabError("已作废的订单不能改状态", code="order_void")
if target == STATUS_VOID:
raise CollabError("已作废的订单不能改状态", code="order_void")
return _restore_void_status(
db,
current,
order,
target=target,
reason=reason,
commit=commit,
)
old = order.status
now = _now()
@@ -1400,6 +1482,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)
+271 -2
View File
@@ -36,6 +36,7 @@ from ..huya_defaults import (
HUYA_DEFAULT_SID,
)
from ..models import (
HuyaCollabGiftCatalog,
HuyaCollabShop,
HuyaConfig,
HuyaExternalOrder,
@@ -2579,6 +2580,7 @@ def export_cks(
# --------------------------------------------------------------------------
SHOP_NAME_LIMIT = 64
GIFT_NAME_LIMIT = 128
def normalize_shop_name(value: Any) -> str:
@@ -2831,6 +2833,257 @@ def list_shops(db: Session) -> list[str]:
return sorted(shop.name for shop in list_shop_records(db))
def _gift_audit_target(gift: HuyaCollabGiftCatalog) -> str:
return f"huya_collab_gift:{gift.id}"
def _touch_gift(gift: HuyaCollabGiftCatalog, current: User | None) -> None:
gift.updated_by = current.id if current else None
gift.updated_at = _now()
def _clean_catalog_points(value: Any) -> int:
points = _clean_required_points(value)
if points is None:
raise CollabError("礼包所需积分不能为空", code="gift_points_invalid")
return points
def _catalog_points_for_name(db: Session, name: str) -> int | None:
clean = str(name or "").strip()
if not clean:
return None
gift = (
db.query(HuyaCollabGiftCatalog)
.filter(HuyaCollabGiftCatalog.name == clean)
.first()
)
return int(gift.points or 0) if gift is not None else None
def serialize_gift(
gift: HuyaCollabGiftCatalog, *, users: Mapping[int, str] | None = None
) -> dict:
names = users or {}
def name_of(value: int | None) -> str:
return names.get(int(value), "") if value else ""
return {
"id": gift.id,
"name": gift.name or "",
"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_name": name_of(gift.created_by),
"updated_by_name": name_of(gift.updated_by),
"created_at": gift.created_at,
"updated_at": gift.updated_at,
}
def list_gift_records(db: Session) -> list[HuyaCollabGiftCatalog]:
"""全部礼包字典项(含停用;前端按 ``is_active`` 决定新建时是否可选)。"""
return (
db.query(HuyaCollabGiftCatalog)
.order_by(
HuyaCollabGiftCatalog.sort_order.asc(),
HuyaCollabGiftCatalog.name.asc(),
)
.all()
)
def get_gift(db: Session, gift_id: int) -> HuyaCollabGiftCatalog:
gift = (
db.query(HuyaCollabGiftCatalog)
.filter(HuyaCollabGiftCatalog.id == gift_id)
.first()
)
if gift is None:
raise CollabError(
"这个礼包不存在(可能已被别人删除)",
status_code=404,
code="gift_not_found",
)
return gift
def _assert_gift_name_available(
db: Session, name: str, *, exclude_id: int | None = None
) -> None:
query = db.query(HuyaCollabGiftCatalog).filter(HuyaCollabGiftCatalog.name == name)
if exclude_id is not None:
query = query.filter(HuyaCollabGiftCatalog.id != exclude_id)
other = query.first()
if other is not None:
raise CollabError(
f"礼包「{name}」已经在字典里了(第 {other.id} 行)",
status_code=409,
code="gift_name_conflict",
)
def create_gift(
db: Session,
current: User | None,
*,
name: Any,
points: Any,
sort_order: int = 0,
remark: Any = "",
commit: bool = True,
) -> HuyaCollabGiftCatalog:
clean_name = clean_text(name, field="gift_name", max_length=GIFT_NAME_LIMIT)
if not clean_name:
raise CollabError("礼包名不能为空", code="gift_name_required")
_assert_gift_name_available(db, clean_name)
gift = HuyaCollabGiftCatalog(
name=clean_name,
points=_clean_catalog_points(points),
sort_order=int(sort_order or 0),
is_active=True,
remark=clean_text(remark, field="gift_remark", max_length=255),
created_by=current.id if current else None,
updated_by=current.id if current else None,
)
db.add(gift)
db.flush()
record_audit(
db,
current,
action="huya:collab:gift_create",
target=_gift_audit_target(gift),
detail={
"name": gift.name,
"points": gift.points,
"sort_order": gift.sort_order,
},
)
if commit:
db.commit()
db.refresh(gift)
return gift
def update_gift(
db: Session,
current: User | None,
gift: HuyaCollabGiftCatalog,
payload: Mapping[str, Any],
*,
commit: bool = True,
) -> dict:
"""修改礼包字典,返回字段级 diff。
改名会级联所有历史订单上的 ``gift_name``;积分只改字典,不回填订单里
已经形成的 ``required_points`` 快照。
"""
changes: dict[str, dict[str, Any]] = {}
old_name = gift.name or ""
renamed_orders = 0
if "name" in payload:
new_name = clean_text(
payload["name"], field="gift_name", max_length=GIFT_NAME_LIMIT
)
if not new_name:
raise CollabError("礼包名不能为空", code="gift_name_required")
if new_name != old_name:
_assert_gift_name_available(db, new_name, exclude_id=gift.id)
changes["name"] = {"old": old_name, "new": new_name}
gift.name = new_name
if "points" in payload:
value = _clean_catalog_points(payload["points"])
if value != int(gift.points or 0):
changes["points"] = {"old": int(gift.points or 0), "new": value}
gift.points = value
if "sort_order" in payload:
value = int(payload["sort_order"] or 0)
if value != int(gift.sort_order or 0):
changes["sort_order"] = {"old": int(gift.sort_order or 0), "new": value}
gift.sort_order = value
if "is_active" in payload:
value = bool(payload["is_active"])
if value != bool(gift.is_active):
changes["is_active"] = {"old": bool(gift.is_active), "new": value}
gift.is_active = value
if "remark" in payload:
value = clean_text(payload["remark"], field="gift_remark", max_length=255)
if value != (gift.remark or ""):
changes["remark"] = {"old": gift.remark or "", "new": value}
gift.remark = value
if not changes:
return {}
if "name" in changes:
renamed_orders = (
db.query(HuyaExternalOrder)
.filter(HuyaExternalOrder.gift_name == old_name)
.update(
{HuyaExternalOrder.gift_name: changes["name"]["new"]},
synchronize_session=False,
)
)
_touch_gift(gift, current)
record_audit(
db,
current,
action="huya:collab:gift_update",
target=_gift_audit_target(gift),
detail={"changes": changes, "renamed_orders": renamed_orders},
)
if commit:
db.commit()
db.refresh(gift)
return changes
def delete_gift(
db: Session,
current: User | None,
gift: HuyaCollabGiftCatalog,
*,
commit: bool = True,
) -> dict:
"""删除未被订单引用的字典项;仍在使用时明确拒绝,避免历史名称失去标准口径。"""
gift_name = gift.name or ""
used = (
db.query(func.count(HuyaExternalOrder.id))
.filter(HuyaExternalOrder.gift_name == gift_name)
.scalar()
or 0
)
if used:
raise CollabError(
f"还有 {used} 个协作单使用「{gift_name}」,请先停用或改名",
code="gift_in_use",
)
snapshot = {
"id": gift.id,
"name": gift_name,
"points": int(gift.points or 0),
}
record_audit(
db,
current,
action="huya:collab:gift_delete",
target=_gift_audit_target(gift),
detail=snapshot,
)
db.delete(gift)
if commit:
db.commit()
return snapshot
def list_gift_names(db: Session) -> list[str]:
"""台账里出现过的礼包/皮肤名(去重、去空;筛选下拉用)。"""
rows = db.query(HuyaExternalOrder.gift_name).distinct().all()
@@ -2935,13 +3188,20 @@ def create_order(
# 草稿单不验 CK、不跑探针:先落一行占住订单号,资料到了在详情里补。
require_verified = False
clean_gift_name = clean_text(
gift_name, field="gift_name", max_length=GIFT_NAME_LIMIT
)
clean_required_points = _clean_required_points(required_points)
if clean_gift_name and clean_required_points is None:
clean_required_points = _catalog_points_for_name(db, clean_gift_name)
order = HuyaExternalOrder(
order_no=order_no,
shop=clean_text(shop, field="shop"),
owner_id=_clean_owner_id(owner_id) if owner_id is not None else current.id,
bind_douyu_account=clean_text(bind_douyu_account, field="bind_douyu_account"),
gift_name=clean_text(gift_name, field="gift_name"),
required_points=_clean_required_points(required_points),
gift_name=clean_gift_name,
required_points=clean_required_points,
remark=clean_text(remark, field="remark"),
bind_images=[],
home_images=home_images_keys,
@@ -3077,6 +3337,15 @@ def update_order(
# 改成字典里没有的名字也允许(自定义),顺手登记,筛选下拉不会漏。
ensure_shops_registered(db, current, [changes["shop"]["new"]])
if "gift_name" in changes and "required_points" not in payload:
catalog_points = _catalog_points_for_name(db, changes["gift_name"]["new"])
if catalog_points is not None and catalog_points != order.required_points:
changes["required_points"] = {
"old": order.required_points,
"new": catalog_points,
}
order.required_points = catalog_points
# 先记「人工改了什么」,再跑自动状态迁移:否则时间线会出现
# status_auto 排在 update 前面,像状态是被后一次操作改的。
record_audit(
+27
View File
@@ -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<DouyuCollabGift[], DouyuCollabGift[]>('/douyu/collab-gifts'),
createGift: (data: { name: string; points: number; remark?: string }) =>
api.post<DouyuCollabGift, DouyuCollabGift>('/douyu/collab-gifts', data),
updateGift: (
id: number,
data: {
name?: string;
points?: number;
sort_order?: number;
is_active?: boolean;
remark?: string;
},
) => api.put<DouyuCollabGift, DouyuCollabGift>(`/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<string[], string[]>('/douyu/external-orders/gifts'),
goods: () => api.get<DouyuCollabGoods[], DouyuCollabGoods[]>('/douyu/external-orders/goods'),
refreshGoods: (accountId?: number) =>
+16
View File
@@ -43,6 +43,9 @@ import type {
HuyaCollabCkRequest,
HuyaCollabCkStatusRequest,
HuyaCollabFrequentGift,
HuyaCollabGiftCatalog,
HuyaCollabGiftCatalogCreate,
HuyaCollabGiftCatalogUpdate,
HuyaCollabImagesRequest,
HuyaCollabImportResult,
HuyaCollabMetric,
@@ -243,6 +246,19 @@ export const huyaApi = {
api.delete<{ success: boolean; name: string }, { success: boolean; name: string }>(
`/huya/collab-shops/${id}`,
),
// ---- 礼包字典(标准名称 + 所需积分;名称编辑会级联历史协作单)----
listCollabGiftCatalog: () =>
api.get<HuyaCollabGiftCatalog[], HuyaCollabGiftCatalog[]>('/huya/collab-gifts'),
createCollabGift: (data: HuyaCollabGiftCatalogCreate) =>
api.post<HuyaCollabGiftCatalog, HuyaCollabGiftCatalog>('/huya/collab-gifts', data),
updateCollabGift: (id: number, data: HuyaCollabGiftCatalogUpdate) =>
api.put<HuyaCollabGiftCatalog, HuyaCollabGiftCatalog>(`/huya/collab-gifts/${id}`, data),
deleteCollabGift: (id: number) =>
api.delete<
{ success: boolean; id: number; name: string; points: number },
{ success: boolean; id: number; name: string; points: number }
>(`/huya/collab-gifts/${id}`),
getCollabOrder: (id: number) =>
api.get<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}`),
createCollabOrder: (data: HuyaCollabOrderCreate) =>
+30
View File
@@ -1824,3 +1824,33 @@ export interface HuyaCollabShopUpdate {
/** 改名时是否级联更新已有订单里的店铺名(默认 true) */
rename_orders?: boolean;
}
/** 协作台账的礼包字典项(与实时活动礼包 HuyaCollabGift 分开) */
export interface HuyaCollabGiftCatalog {
id: number;
name: string;
points: number;
sort_order: number;
/** false = 停用(不再出现在可选项里;已有订单仍显示原值) */
is_active: boolean;
remark: string;
created_by_name: string;
updated_by_name: string;
created_at: string | null;
updated_at: string | null;
}
export interface HuyaCollabGiftCatalogCreate {
name: string;
points: number;
sort_order?: number;
remark?: string;
}
export interface HuyaCollabGiftCatalogUpdate {
name?: string;
points?: number;
sort_order?: number;
is_active?: boolean;
remark?: string;
}
@@ -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<number | null>(null);
const [busy, setBusy] = useState(false);
const [editTarget, setEditTarget] = useState<DouyuCollabGift | null>(null);
const [editName, setEditName] = useState('');
const [editPoints, setEditPoints] = useState<number | null>(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<DouyuCollabGift>['columns'] = [
{
title: '礼包名',
dataIndex: 'name',
width: 260,
render: (value: string, row) => (
<Space size={4}>
<Text strong>{value}</Text>
{!row.is_active && <Tag>已停用</Tag>}
{canManage && (
<Tooltip title="编辑名称、积分和备注">
<Button
size="small"
type="text"
icon={<EditOutlined />}
disabled={busy}
onClick={() => openEdit(row)}
/>
</Tooltip>
)}
</Space>
),
},
{
title: '所需积分',
dataIndex: 'points',
width: 110,
render: (value: number) => <Text strong>{value}</Text>,
},
{
title: '排序',
dataIndex: 'sort_order',
width: 100,
render: (value: number, row) => (
<InputNumber
size="small"
value={value}
disabled={!canManage || busy}
onChange={(next) => {
if (next === null || next === value) return;
void patch(row, { sort_order: Number(next) });
}}
/>
),
},
{
title: '新建时可选',
dataIndex: 'is_active',
width: 120,
align: 'center',
render: (value: boolean, row) => (
<Switch
size="small"
checked={value}
disabled={!canManage || busy}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => void patch(row, { is_active: checked })}
/>
),
},
{
title: '备注',
dataIndex: 'remark',
render: (value: string, row) => (
<Input
size="small"
defaultValue={value}
disabled={!canManage || busy}
placeholder="备注(失焦保存)"
onBlur={(event) => {
const next = event.target.value.trim();
if (next !== value) void patch(row, { remark: next });
}}
/>
),
},
{
title: '操作',
width: 80,
align: 'center',
render: (_, row) =>
canManage ? (
<Popconfirm
title={`删除礼包「${row.name}」?`}
description="仍有协作单使用该礼包时会被拒绝,可改用停用"
onConfirm={() => void remove(row)}
disabled={busy}
>
<Button size="small" danger type="text" icon={<DeleteOutlined />} disabled={busy} />
</Popconfirm>
) : (
<Text type="secondary">-</Text>
),
},
];
return (
<>
<Modal
title="礼包管理"
open={open}
onCancel={onClose}
footer={<Button onClick={onClose}>关闭</Button>}
width={900}
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Text type="secondary">
先在这里维护标准礼包名和所需积分,新建协作单时直接选择。礼包名请按兑换记录里的名称填写;
停用只是不再出现在新建选择里,历史订单不受影响;改名会同步已有订单,改积分只影响之后新建的单。
</Text>
{canManage && (
<Row gutter={8}>
<Col flex="auto">
<Input
placeholder="礼包名(按兑换记录标准名填写),回车添加"
value={newName}
maxLength={128}
onChange={(event) => setNewName(event.target.value)}
onPressEnter={() => void create()}
/>
</Col>
<Col>
<InputNumber
style={{ width: 140 }}
min={0}
precision={0}
placeholder="所需积分"
value={newPoints}
onChange={(value) => setNewPoints(value ?? null)}
/>
</Col>
<Col>
<Button type="primary" icon={<PlusOutlined />} loading={busy} onClick={() => void create()}>
添加
</Button>
</Col>
</Row>
)}
<Table<DouyuCollabGift>
rowKey="id"
size="small"
columns={columns}
dataSource={gifts}
pagination={false}
scroll={{ y: 340 }}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有礼包,先加一个" />,
}}
/>
</Space>
</Modal>
<Modal
title={editTarget ? `编辑礼包「${editTarget.name}」` : '编辑礼包'}
open={!!editTarget}
onCancel={() => setEditTarget(null)}
onOk={() => void submitEdit()}
confirmLoading={busy}
okText="保存"
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
value={editName}
maxLength={128}
autoFocus
placeholder="礼包名"
onChange={(event) => setEditName(event.target.value)}
onPressEnter={() => void submitEdit()}
/>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={0}
value={editPoints}
placeholder="所需积分"
onChange={(value) => setEditPoints(value ?? null)}
/>
<Input
value={editRemark}
maxLength={255}
placeholder="备注(可选)"
onChange={(event) => setEditRemark(event.target.value)}
/>
<Text type="secondary">
改名会同步已有协作单上的礼包名;修改积分只影响之后新建的协作单,不会改变历史订单。
</Text>
</Space>
</Modal>
</>
);
}
@@ -0,0 +1,368 @@
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 { huyaApi, type HuyaCollabGiftCatalog } from '../api/modules';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
/**
* 虎牙协作礼包字典:管理员维护标准名称和所需积分,客服只能选择。
*
* - 停用后不再出现在新建下拉里,历史订单继续显示原值;
* - 改名会同步所有历史订单上的礼包名;
* - 改积分只影响之后新建 / 换礼包时的默认值,不回填历史积分快照;
* - 删除只删字典行,仍有订单使用时会拒绝。
*/
export default function HuyaCollabGiftModal({
open,
gifts,
canManage,
onClose,
onChanged,
}: {
open: boolean;
gifts: HuyaCollabGiftCatalog[];
canManage: boolean;
onClose: () => void;
onChanged: () => void;
}) {
const [newName, setNewName] = useState('');
const [newPoints, setNewPoints] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const [editTarget, setEditTarget] = useState<HuyaCollabGiftCatalog | null>(null);
const [editName, setEditName] = useState('');
const [editPoints, setEditPoints] = useState<number | null>(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 huyaApi.createCollabGift({ name, points: newPoints });
message.success(`已添加礼包「${name}」`);
setNewName('');
setNewPoints(null);
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setBusy(false);
}
};
const patch = async (
gift: HuyaCollabGiftCatalog,
payload: { sort_order?: number; is_active?: boolean; remark?: string },
) => {
setBusy(true);
try {
await huyaApi.updateCollabGift(gift.id, payload);
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
onChanged();
} finally {
setBusy(false);
}
};
const openEdit = (gift: HuyaCollabGiftCatalog) => {
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 huyaApi.updateCollabGift(editTarget.id, {
name,
points: editPoints,
remark: editRemark.trim(),
});
message.success(
name === editTarget.name
? `已保存礼包「${name}」`
: `已改名为「${name}」,历史协作单里的礼包名也已同步`,
);
setEditTarget(null);
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setBusy(false);
}
};
const remove = async (gift: HuyaCollabGiftCatalog) => {
setBusy(true);
try {
await huyaApi.deleteCollabGift(gift.id);
message.success(`已删除礼包「${gift.name}」`);
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setBusy(false);
}
};
const columns: TableProps<HuyaCollabGiftCatalog>['columns'] = [
{
title: '礼包名',
dataIndex: 'name',
width: 280,
render: (value: string, row) => (
<Space size={4}>
<Text strong>{value}</Text>
{!row.is_active && <Tag>已停用</Tag>}
{canManage && (
<Tooltip title="编辑名称、积分和备注">
<Button
size="small"
type="text"
icon={<EditOutlined />}
disabled={busy}
onClick={() => openEdit(row)}
/>
</Tooltip>
)}
</Space>
),
},
{
title: '所需积分',
dataIndex: 'points',
width: 110,
render: (value: number) => <Text strong>{value}</Text>,
},
{
title: '排序',
dataIndex: 'sort_order',
width: 100,
render: (value: number, row) => (
<InputNumber
size="small"
value={value}
disabled={!canManage || busy}
onChange={(next) => {
if (next === null || next === value) return;
void patch(row, { sort_order: Number(next) });
}}
/>
),
},
{
title: '新建时可选',
dataIndex: 'is_active',
width: 120,
align: 'center',
render: (value: boolean, row) => (
<Switch
size="small"
checked={value}
disabled={!canManage || busy}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => void patch(row, { is_active: checked })}
/>
),
},
{
title: '备注',
dataIndex: 'remark',
render: (value: string, row) => (
<Input
size="small"
defaultValue={value}
disabled={!canManage || busy}
placeholder="备注(失焦保存)"
onBlur={(event) => {
const next = event.target.value.trim();
if (next !== value) void patch(row, { remark: next });
}}
/>
),
},
{
title: '最后改动',
width: 150,
render: (_, row) => (
<Text type="secondary" style={{ fontSize: 12 }}>
{row.updated_by_name || '-'}
{row.updated_at ? ` · ${new Date(row.updated_at).toLocaleString()}` : ''}
</Text>
),
},
{
title: '操作',
width: 80,
align: 'center',
render: (_, row) =>
canManage ? (
<Popconfirm
title={`删除礼包「${row.name}」?`}
description="仍有协作单使用该礼包时会被拒绝,可改用停用"
onConfirm={() => void remove(row)}
disabled={busy}
>
<Button size="small" danger type="text" icon={<DeleteOutlined />} disabled={busy} />
</Popconfirm>
) : (
<Text type="secondary">-</Text>
),
},
];
return (
<>
<Modal
title="礼包管理"
open={open}
onCancel={onClose}
footer={<Button onClick={onClose}>关闭</Button>}
width={940}
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Text type="secondary">
先在这里维护标准礼包名和所需积分,新建 / 补资料时可以直接选择。礼包名请按兑换记录里的名称填写;
停用只是不再出现在可选列表里,历史订单不受影响;改名会同步已有订单,改积分只影响之后新建或换礼包的单。
</Text>
{canManage && (
<Row gutter={8}>
<Col flex="auto">
<Input
placeholder="礼包名(按兑换记录标准名填写),回车添加"
value={newName}
maxLength={128}
onChange={(event) => setNewName(event.target.value)}
onPressEnter={() => void create()}
/>
</Col>
<Col>
<InputNumber
style={{ width: 140 }}
min={0}
precision={0}
placeholder="所需积分"
value={newPoints}
onChange={(value) => setNewPoints(value ?? null)}
/>
</Col>
<Col>
<Button
type="primary"
icon={<PlusOutlined />}
loading={busy}
onClick={() => void create()}
>
添加
</Button>
</Col>
</Row>
)}
<Table<HuyaCollabGiftCatalog>
rowKey="id"
size="small"
columns={columns}
dataSource={gifts}
pagination={false}
scroll={{ y: 340 }}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="还没有礼包,先加一个"
/>
),
}}
/>
</Space>
</Modal>
<Modal
title={editTarget ? `编辑礼包「${editTarget.name}」` : '编辑礼包'}
open={!!editTarget}
onCancel={() => setEditTarget(null)}
onOk={() => void submitEdit()}
confirmLoading={busy}
okText="保存"
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
value={editName}
maxLength={128}
autoFocus
placeholder="礼包名"
onChange={(event) => setEditName(event.target.value)}
onPressEnter={() => void submitEdit()}
/>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={0}
value={editPoints}
placeholder="所需积分"
onChange={(value) => setEditPoints(value ?? null)}
/>
<Input
value={editRemark}
maxLength={255}
placeholder="备注(可选)"
onChange={(event) => setEditRemark(event.target.value)}
/>
<Text type="secondary">
改名会同步所有已有协作单上的礼包名;修改积分只影响之后新建或换礼包的单,不会改变历史订单。
</Text>
</Space>
</Modal>
</>
);
}
+1 -1
View File
@@ -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;
+181 -43
View File
@@ -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<Array<{ id: number; username: string }>>([]);
const [shopRecords, setShopRecords] = useState<DouyuCollabShop[]>([]);
const [giftRecords, setGiftRecords] = useState<DouyuCollabGift[]>([]);
const [gifts, setGifts] = useState<string[]>([]);
const [goods, setGoods] = useState<DouyuCollabGoods[]>([]);
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) => {
@@ -715,6 +748,14 @@ export default function DouyuCollabPage() {
setBatchStatusRollback(false);
};
const openRestoreVoid = (order: DouyuCollabOrder) => {
setStatusTarget(order);
setBatchStatusOpen(false);
setStatusValue('incomplete');
setStatusReason('');
setBatchStatusRollback(false);
};
const openBatchStatus = () => {
if (selectedIds.length === 0) {
message.warning('请先勾选协作单');
@@ -767,13 +808,14 @@ export default function DouyuCollabPage() {
} else if (statusTarget) {
const currentIndex = FLOW_ORDER.indexOf(statusTarget.status);
const targetIndex = FLOW_ORDER.indexOf(statusValue);
const restoringVoid = statusTarget.status === 'void';
await douyuCollabApi.setStatus(statusTarget.id, {
status: statusValue,
reason: statusReason,
rollback: targetIndex >= 0 && currentIndex >= 0 && targetIndex < currentIndex,
expected_version: statusTarget.version,
});
message.success('状态已更新');
message.success(restoringVoid ? '已取消作废并恢复状态' : '状态已更新');
setSelectedRowKeys((keys) => keys.filter((key) => Number(key) !== statusTarget.id));
setStatusTarget(null);
}
@@ -1317,6 +1359,17 @@ export default function DouyuCollabPage() {
</Dropdown>
</>
)}
{canEdit && !row.is_archived && row.status === 'void' && isAdmin && (
<Tooltip title="仅超级管理员可将已作废订单恢复为其它状态">
<Button
size="small"
icon={<UndoOutlined />}
onClick={() => openRestoreVoid(row)}
>
取消作废
</Button>
</Tooltip>
)}
{row.is_archived && canEdit && (
<Button size="small" icon={<UndoOutlined />} onClick={() => void unarchiveOrders(row)}>
取消归档
@@ -1328,21 +1381,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<string>();
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 (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
@@ -1637,6 +1712,11 @@ export default function DouyuCollabPage() {
刷新活动商品
</Button>
)}
{canManage && (
<Button icon={<GiftOutlined />} onClick={() => setGiftModalOpen(true)}>
礼包管理
</Button>
)}
{canEdit && (
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>
店铺管理
@@ -1689,6 +1769,14 @@ export default function DouyuCollabPage() {
onChanged={refreshAll}
/>
<DouyuCollabGiftModal
open={giftModalOpen}
gifts={giftRecords}
canManage={canManage}
onClose={() => setGiftModalOpen(false)}
onChanged={refreshAll}
/>
<Modal
title={editing ? `编辑协作单 ${editing.order_no}` : '新建协作单'}
open={createOpen}
@@ -1814,19 +1902,40 @@ export default function DouyuCollabPage() {
<Col span={14}>
<div style={{ marginBottom: 12 }}>
<Text strong>礼包</Text>
<Select
style={{ width: '100%', marginTop: 4 }}
showSearch
allowClear
optionFilterProp="label"
value={form.commodity_id || undefined}
options={giftOptions}
placeholder={goodsPool.length ? '选择 23返场 / 常驻礼包' : '商品缓存为空,点右上角刷新活动商品'}
onChange={handleGiftChange}
/>
{canManage ? (
<AutoComplete
style={{ width: '100%', marginTop: 4 }}
allowClear
value={form.gift_name}
options={giftNameOptions}
placeholder={
goodsPool.length || giftRecords.length
? '输入礼包名称,或选择已有礼包'
: '输入标准礼包名称(接口未返回商品时手动填写)'
}
onChange={handleGiftNameChange}
/>
) : (
<Select
style={{ width: '100%', marginTop: 4 }}
allowClear
showSearch
optionFilterProp="label"
value={form.gift_name || undefined}
options={giftNameOptions}
placeholder={
giftNameOptions.length
? '选择礼包(由管理员统一维护)'
: '暂无可选礼包,请联系管理员配置'
}
notFoundContent="没有可选礼包,请联系管理员配置"
onChange={handleGiftNameChange}
/>
)}
{form.gift_name && (
<Text type="secondary" style={{ fontSize: 12 }}>
当前:{form.gift_name}
名称请按兑换记录里的标准礼包名填写
{form.commodity_id ? ` · 已关联接口商品 ${form.commodity_id}` : ' · 手动配置'}
</Text>
)}
</div>
@@ -1837,8 +1946,10 @@ export default function DouyuCollabPage() {
<InputNumber
style={{ width: '100%', marginTop: 4 }}
min={0}
precision={0}
value={form.required_points}
placeholder="选中礼包自动带出"
disabled={!canManage}
placeholder={canManage ? '手动填写;选择礼包后自动带出' : '由管理员统一配置'}
onChange={(value) => updateForm('required_points', value ?? null)}
/>
</div>
@@ -1869,7 +1980,13 @@ export default function DouyuCollabPage() {
</Modal>
<Modal
title={batchStatusOpen ? `批量改状态(${selectedCount} 单)` : `改状态:${statusTarget?.order_no || ''}`}
title={
batchStatusOpen
? `批量改状态(${selectedCount} 单)`
: statusTarget?.status === 'void'
? `取消作废:${statusTarget.order_no}`
: `改状态:${statusTarget?.order_no || ''}`
}
open={batchStatusOpen || Boolean(statusTarget)}
onCancel={() => {
setBatchStatusOpen(false);
@@ -1877,7 +1994,13 @@ export default function DouyuCollabPage() {
}}
onOk={() => void submitStatus()}
confirmLoading={statusBusy}
okText={batchStatusOpen ? `改成「${STATUS_META[statusValue].label}」` : '确定'}
okText={
batchStatusOpen
? `改成「${STATUS_META[statusValue].label}」`
: statusTarget?.status === 'void'
? `恢复为「${STATUS_META[statusValue].label}」`
: '确定'
}
okButtonProps={{
disabled: batchStatusOpen && batchRollbackRequired && !batchStatusRollback,
}}
@@ -1887,13 +2010,26 @@ export default function DouyuCollabPage() {
style={{ width: '100%' }}
value={statusValue}
onChange={(value: DouyuCollabStatus) => setStatusValue(value)}
options={STATUS_ORDER.map((item) => ({
options={STATUS_ORDER.filter(
(item) => !(statusTarget?.status === 'void' && item === 'void'),
).map((item) => ({
value: item,
label: STATUS_META[item].label,
}))}
/>
{statusValue === 'void' && (
<Alert type="warning" showIcon message="作废后不可恢复;请在备注里写作废原因。" />
<Alert
type="warning"
showIcon
message="作废后普通用户不能恢复;如需恢复,请由管理员取消作废。"
/>
)}
{statusTarget?.status === 'void' && (
<Alert
type="warning"
showIcon
message="管理员操作:订单会恢复为所选状态,原作废原因会保留在审计记录中。"
/>
)}
{batchStatusOpen && (
<>
@@ -1918,7 +2054,9 @@ export default function DouyuCollabPage() {
<Input.TextArea
rows={2}
value={statusReason}
placeholder="原因 / 备注(可选)"
placeholder={
statusTarget?.status === 'void' ? '取消作废原因(可选)' : '原因 / 备注(可选)'
}
onChange={(event) => setStatusReason(event.target.value)}
/>
</Space>
+88 -41
View File
@@ -27,6 +27,7 @@ import {
type HuyaCollabCkPreview,
type HuyaCollabFrequentGift,
type HuyaCollabGift,
type HuyaCollabGiftCatalog,
type HuyaCollabImageSlot,
type HuyaCollabMetric,
type HuyaCollabOrder,
@@ -40,6 +41,7 @@ import {
import HuyaCollabShopModal from '../components/HuyaCollabShopModal';
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
import HuyaCollabGiftModal from '../components/HuyaCollabGiftModal';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
import { copyTextToClipboard } from '../utils/clipboard';
@@ -513,10 +515,12 @@ export default function HuyaCollabPage() {
const [summary, setSummary] = useState<HuyaCollabSummary | null>(null);
const [owners, setOwners] = useState<HuyaCollabOwner[]>([]);
const [shops, setShops] = useState<HuyaCollabShop[]>([]);
const [giftCatalog, setGiftCatalog] = useState<HuyaCollabGiftCatalog[]>([]);
const [giftNames, setGiftNames] = useState<string[]>([]);
// 「常用礼包」:台账里用得最多的几个(新建/补资料的礼包名快捷按钮,见 2026-10-18 需求)
const [frequentGifts, setFrequentGifts] = useState<HuyaCollabFrequentGift[]>([]);
const [shopModalOpen, setShopModalOpen] = useState(false);
const [giftModalOpen, setGiftModalOpen] = useState(false);
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({
include_void: false,
archive_scope: 'active',
@@ -616,6 +620,7 @@ export default function HuyaCollabPage() {
nextSummary,
nextOwners,
nextShops,
nextGiftCatalog,
nextGiftNames,
nextFrequentGifts,
] = await Promise.all([
@@ -632,6 +637,7 @@ export default function HuyaCollabPage() {
}),
withMeta ? huyaApi.collabOrderOwners() : Promise.resolve(null),
withMeta ? huyaApi.listCollabShops() : Promise.resolve(null),
withMeta ? huyaApi.listCollabGiftCatalog() : Promise.resolve(null),
withMeta ? huyaApi.collabOrderGifts() : Promise.resolve(null),
withMeta ? huyaApi.collabFrequentGifts() : Promise.resolve(null),
]);
@@ -640,6 +646,7 @@ export default function HuyaCollabPage() {
setSummary(nextSummary);
if (nextOwners) setOwners(nextOwners);
if (nextShops) setShops(nextShops);
if (nextGiftCatalog) setGiftCatalog(nextGiftCatalog);
if (nextGiftNames) setGiftNames(nextGiftNames);
if (nextFrequentGifts) setFrequentGifts(nextFrequentGifts);
// 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 id。
@@ -657,7 +664,7 @@ export default function HuyaCollabPage() {
// 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。
const realtimeSyncEnabled = realtimeEnabled && !realtimePaused;
const collabModalOpen = Boolean(
createOpen || supplementTarget || detail || statusAction || batchStatusOpen || rechargeOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen,
createOpen || supplementTarget || detail || statusAction || batchStatusOpen || rechargeOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen || giftModalOpen,
);
const toggleRealtimeSync = (nextEnabled: boolean) => {
@@ -761,12 +768,16 @@ export default function HuyaCollabPage() {
}
if (preview.ok && preview.has_role) {
message.success(`CK 有效:绑定角色 ${preview.bind_game_role}`);
// 「记住的礼包」如果不在当前列表里就清掉重选;列表为空时不动它(允许手填)
// 「记住的礼包」如果不在实时列表、也不在标准字典里就清掉重选。
const options = preview.gifts.length > 0 ? preview.gifts : giftsRef.current;
const available = new Set([
...options.map((gift) => gift.name),
...giftCatalog.filter((gift) => gift.is_active).map((gift) => gift.name),
]);
const picked = String(createForm.getFieldValue('gift_name') ?? '').trim();
if (picked && options.length > 0 && !options.some((gift) => gift.name === picked)) {
if (picked && available.size > 0 && !available.has(picked)) {
createForm.setFieldValue('gift_name', undefined);
setGiftHint(`上次选的「${picked}」不在当前的礼包列表里,已清空,请重新点选`);
setGiftHint(`上次选的「${picked}」不在实时礼包或标准字典里,已清空,请重新点选`);
} else {
setGiftHint('');
}
@@ -778,7 +789,7 @@ export default function HuyaCollabPage() {
if (seq === previewSeq.current) setPreviewing(false);
}
},
[emptyPreview, createForm],
[emptyPreview, createForm, giftCatalog],
);
/**
@@ -1825,33 +1836,49 @@ export default function HuyaCollabPage() {
: giftOptions.length > 0
? `礼包用本地缓存(${giftOptions.length} 个${giftCacheLabel ? ` · ${giftCacheLabel}` : ''}),要更新点「强制刷新礼包」`
: (ckPreview?.gifts_message || '');
const hasSelectableGifts = giftOptions.length > 0
|| giftCatalog.some((gift) => gift.is_active);
/**
* 礼包下拉选项:只允许点选缓存里的礼包,不接受手输。
* 当前值是历史/已下线礼包时补一个只读选项,保证界面能显示,但依然只能从下拉里选。
*/
/** 礼包下拉:实时活动礼包 + 管理员维护的标准字典,二者按名称去重。 */
const giftSelectOptions = (current?: string | null) => {
const value = String(current ?? '').trim();
const options = giftOptions.map((gift) => ({
value: gift.name,
label: [
gift.category_name ? `[${gift.category_name}]` : '',
gift.name,
gift.price ? `· ${gift.price} 积分` : '',
gift.remain_text ? `· 剩 ${gift.remain_text}` : '',
].filter(Boolean).join(' '),
}));
if (value && !giftOptions.some((gift) => gift.name === value)) {
const seen = new Set<string>();
const options = giftOptions
.filter((gift) => {
if (!gift.name || seen.has(gift.name)) return false;
seen.add(gift.name);
return true;
})
.map((gift) => ({
value: gift.name,
label: [
gift.category_name ? `[${gift.category_name}]` : '',
gift.name,
gift.price ? `· ${gift.price} 积分` : '',
gift.remain_text ? `· 剩 ${gift.remain_text}` : '',
].filter(Boolean).join(' '),
}));
for (const gift of giftCatalog) {
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} 积分 · 标准字典`,
});
}
if (value && !seen.has(value)) {
options.unshift({ value, label: `${value}(历史值)` });
}
return options;
};
/** 礼包名对应的积分单价;缓存里没有 / 没价格时返回 null(后端核账时还会按名称补一次)。 */
/** 礼包名对应的积分:实时价格优先,其次标准字典;都没有时返回 null。 */
const giftRequiredPoints = (name?: string | null): number | null => {
const value = String(name ?? '').trim();
if (!value) return null;
return giftOptions.find((gift) => gift.name === value)?.price ?? null;
return giftOptions.find((gift) => gift.name === value)?.price
?? giftCatalog.find((gift) => gift.name === value)?.points
?? null;
};
/**
@@ -1863,8 +1890,11 @@ export default function HuyaCollabPage() {
* 点一个拉不到的名字等于白点(见设计文档 §11.4)。缓存为空时整行不显示。
*/
const frequentGiftChoices = (): HuyaCollabFrequentGift[] => {
if (giftOptions.length === 0) return [];
const available = new Set(giftOptions.map((gift) => gift.name));
const available = new Set([
...giftOptions.map((gift) => gift.name),
...giftCatalog.filter((gift) => gift.is_active).map((gift) => gift.name),
]);
if (available.size === 0) return [];
return frequentGifts.filter((item) => available.has(item.name)).slice(0, 6);
};
@@ -2107,7 +2137,7 @@ export default function HuyaCollabPage() {
// 前缀单独一行、礼包名完整换行,别让单行省略号把名字吃掉
labelRender={() => renderGiftSelectedLabel(row.gift_name)}
styles={{ content: { whiteSpace: 'normal', textOverflow: 'clip', lineHeight: 1.3 } }}
notFoundContent={giftOptions.length === 0 ? '礼包缓存为空:先在「新建」里贴 CK 拉一次' : '没有匹配的礼包'}
notFoundContent={hasSelectableGifts ? '没有匹配的礼包' : '实时礼包为空,标准字典也还没有配置'}
onChange={(next: string) => void patchOrder(row, {
gift_name: next,
required_points: giftRequiredPoints(next),
@@ -2528,6 +2558,9 @@ export default function HuyaCollabPage() {
{canEdit && !isArchiveView && (
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>店铺管理</Button>
)}
{canManage && !isArchiveView && (
<Button icon={<GiftOutlined />} onClick={() => setGiftModalOpen(true)}>礼包管理</Button>
)}
{canEdit && !isArchiveView && (
<Button
type="primary"
@@ -2776,16 +2809,16 @@ export default function HuyaCollabPage() {
</Col>
<Col span={12}>
<Form.Item name="gift_name" label="礼包名"
tooltip="只接受礼包缓存里的点选结果,可输入关键字搜索;不接受手工输入">
tooltip="从实时活动礼包或管理员维护的标准字典里点选,可输入关键字搜索">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="输入关键字搜索,点选礼包"
options={giftSelectOptions(detail.gift_name)}
notFoundContent={giftOptions.length === 0
? '礼包缓存为空:先在「新建」里贴 CK 拉一次'
: '没有匹配的礼包'}
notFoundContent={hasSelectableGifts
? '没有匹配的礼包'
: '实时礼包为空,标准字典也还没有配置'}
onChange={(value?: string) => {
detailForm.setFieldValue('required_points', giftRequiredPoints(value));
}}
@@ -3117,20 +3150,27 @@ export default function HuyaCollabPage() {
<Form.Item
name="gift_name"
label="礼包名(用户购买的物品,可稍后补)"
tooltip="默认空白,不会自动带上次选的礼包(2026-10-18)。只接受礼包缓存里的点选结果,可输入关键字搜索;不选也能先建单,之后点「补资料」再选。"
extra={giftHint || (giftsCachedAt && giftOptions.length > 0
? `礼包列表缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
: undefined)}
tooltip="默认空白,不会自动带上次选的礼包。可从实时活动礼包或管理员维护的标准字典里点选;不选也能先建单,之后点「补资料」再选。"
extra={giftHint || [
giftsCachedAt && giftOptions.length > 0
? `实时礼包缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
: '',
giftCatalog.length > 0
? `标准字典 ${giftCatalog.filter((gift) => gift.is_active).length} 个可用`
: '',
].filter(Boolean).join(';') || undefined}
validateStatus={giftHint ? 'warning' : undefined}
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder={giftOptions.length > 0 ? '输入关键字搜索,点选礼包' : '礼包缓存为空:先贴 CK 拉取'}
placeholder={hasSelectableGifts ? '输入关键字搜索,点选礼包' : '请先在上方「礼包管理」配置标准礼包'}
options={giftSelectOptions(createGiftValue)}
disabled={giftOptions.length === 0 && !createGiftValue}
notFoundContent={giftOptions.length === 0 ? '先在左侧贴一份有效 CK 拉取礼包列表' : '没有匹配的礼包'}
disabled={!hasSelectableGifts && !createGiftValue}
notFoundContent={hasSelectableGifts
? '没有匹配的礼包'
: '实时礼包为空,标准字典也还没有配置'}
onChange={(value?: string) => {
// 选了就清掉「上次那个礼包不在本次列表里」的提示,并带出积分价格
setGiftHint('');
@@ -3256,17 +3296,17 @@ export default function HuyaCollabPage() {
}),
)}
<Form.Item name="gift_name" label="礼包名"
tooltip="只接受礼包缓存里的点选结果,可输入关键字搜索;不接受手工输入">
tooltip="从实时活动礼包或管理员维护的标准字典里点选,可输入关键字搜索">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder={giftOptions.length > 0 ? '输入关键字搜索,点选礼包' : '礼包缓存为空:先在「新建」里贴 CK 拉一次'}
placeholder={hasSelectableGifts ? '输入关键字搜索,点选礼包' : '请先让管理员配置标准礼包'}
options={giftSelectOptions(supplementGiftValue ?? supplementTarget.gift_name)}
disabled={giftOptions.length === 0 && !supplementGiftValue}
notFoundContent={giftOptions.length === 0
? '礼包缓存为空:先在「新建」里贴 CK 拉一次'
: '没有匹配的礼包'}
disabled={!hasSelectableGifts && !supplementGiftValue}
notFoundContent={hasSelectableGifts
? '没有匹配的礼包'
: '实时礼包为空,标准字典也还没有配置'}
onChange={(value?: string) => {
supplementForm.setFieldValue('required_points', giftRequiredPoints(value));
}}
@@ -3587,6 +3627,13 @@ export default function HuyaCollabPage() {
onClose={() => setShopModalOpen(false)}
onChanged={() => void load()}
/>
<HuyaCollabGiftModal
open={giftModalOpen}
gifts={giftCatalog}
canManage={canManage}
onClose={() => setGiftModalOpen(false)}
onChanged={() => void load()}
/>
</Space>
);
}