feat(huya): 增加协作单礼包字典与名称编辑
This commit is contained in:
@@ -1051,6 +1051,30 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
|
|||||||
2. **不要让人工直写 `recharged` / `points_error`**:这两个状态必须由核账产生,否则积分快照与状态会各说各话。
|
2. **不要让人工直写 `recharged` / `points_error`**:这两个状态必须由核账产生,否则积分快照与状态会各说各话。
|
||||||
3. **换 CK 不等于资料缺失**:`set_ck()` 只重置 `ck_status`;核账会强制重验,若绑定账号/角色变了整组报错,不会误落旧组。
|
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. 风险
|
## 12. 风险
|
||||||
|
|||||||
@@ -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"] == "按兑换记录标准名"
|
||||||
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
|
|||||||
PROJECT_ROOT = TESTS_DIR.parent
|
PROJECT_ROOT = TESTS_DIR.parent
|
||||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||||
|
|
||||||
HEAD_REVISION = "20261106_0066"
|
HEAD_REVISION = "20261107_0067"
|
||||||
|
|
||||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||||
EXTRA_INDEXES = (
|
EXTRA_INDEXES = (
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1423,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):
|
class HuyaExternalOrder(Base):
|
||||||
"""虎牙外部 CK 协作台账(替代 Excel 多人协同表)。
|
"""虎牙外部 CK 协作台账(替代 Excel 多人协同表)。
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ from ...schemas import (
|
|||||||
HuyaCollabCkPreviewOut,
|
HuyaCollabCkPreviewOut,
|
||||||
HuyaCollabCkPreviewRequest,
|
HuyaCollabCkPreviewRequest,
|
||||||
HuyaCollabFrequentGiftOut,
|
HuyaCollabFrequentGiftOut,
|
||||||
|
HuyaCollabGiftCatalogCreate,
|
||||||
|
HuyaCollabGiftCatalogOut,
|
||||||
|
HuyaCollabGiftCatalogUpdate,
|
||||||
HuyaCollabMetricOut,
|
HuyaCollabMetricOut,
|
||||||
HuyaCollabShopCreate,
|
HuyaCollabShopCreate,
|
||||||
HuyaCollabShopOut,
|
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))
|
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
|
# 字面量路径必须声明在 /{order_id} 之前,否则 "summary" 会被当成 id
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -239,6 +247,80 @@ def delete_collab_shop(
|
|||||||
return {"success": True, "name": snapshot["name"]}
|
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)
|
@router.get("/external-orders", response_model=HuyaExternalOrderListOut)
|
||||||
def list_external_orders(
|
def list_external_orders(
|
||||||
shop: str = Query("", max_length=64),
|
shop: str = Query("", max_length=64),
|
||||||
|
|||||||
@@ -1184,6 +1184,40 @@ class HuyaCollabShopOut(BaseModel):
|
|||||||
updated_at: datetime | None = None
|
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) ----
|
# ---- 虎牙外部 CK 协作台账(见 docs/设计-虎牙外部CK协作台账.md) ----
|
||||||
class HuyaCollabGiftOut(BaseModel):
|
class HuyaCollabGiftOut(BaseModel):
|
||||||
"""活动礼包(用一份有效 CK 现拉,不落库)。"""
|
"""活动礼包(用一份有效 CK 现拉,不落库)。"""
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from ..huya_defaults import (
|
|||||||
HUYA_DEFAULT_SID,
|
HUYA_DEFAULT_SID,
|
||||||
)
|
)
|
||||||
from ..models import (
|
from ..models import (
|
||||||
|
HuyaCollabGiftCatalog,
|
||||||
HuyaCollabShop,
|
HuyaCollabShop,
|
||||||
HuyaConfig,
|
HuyaConfig,
|
||||||
HuyaExternalOrder,
|
HuyaExternalOrder,
|
||||||
@@ -2579,6 +2580,7 @@ def export_cks(
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
SHOP_NAME_LIMIT = 64
|
SHOP_NAME_LIMIT = 64
|
||||||
|
GIFT_NAME_LIMIT = 128
|
||||||
|
|
||||||
|
|
||||||
def normalize_shop_name(value: Any) -> str:
|
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))
|
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]:
|
def list_gift_names(db: Session) -> list[str]:
|
||||||
"""台账里出现过的礼包/皮肤名(去重、去空;筛选下拉用)。"""
|
"""台账里出现过的礼包/皮肤名(去重、去空;筛选下拉用)。"""
|
||||||
rows = db.query(HuyaExternalOrder.gift_name).distinct().all()
|
rows = db.query(HuyaExternalOrder.gift_name).distinct().all()
|
||||||
@@ -2935,13 +3188,20 @@ def create_order(
|
|||||||
# 草稿单不验 CK、不跑探针:先落一行占住订单号,资料到了在详情里补。
|
# 草稿单不验 CK、不跑探针:先落一行占住订单号,资料到了在详情里补。
|
||||||
require_verified = False
|
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 = HuyaExternalOrder(
|
||||||
order_no=order_no,
|
order_no=order_no,
|
||||||
shop=clean_text(shop, field="shop"),
|
shop=clean_text(shop, field="shop"),
|
||||||
owner_id=_clean_owner_id(owner_id) if owner_id is not None else current.id,
|
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"),
|
bind_douyu_account=clean_text(bind_douyu_account, field="bind_douyu_account"),
|
||||||
gift_name=clean_text(gift_name, field="gift_name"),
|
gift_name=clean_gift_name,
|
||||||
required_points=_clean_required_points(required_points),
|
required_points=clean_required_points,
|
||||||
remark=clean_text(remark, field="remark"),
|
remark=clean_text(remark, field="remark"),
|
||||||
bind_images=[],
|
bind_images=[],
|
||||||
home_images=home_images_keys,
|
home_images=home_images_keys,
|
||||||
@@ -3077,6 +3337,15 @@ def update_order(
|
|||||||
# 改成字典里没有的名字也允许(自定义),顺手登记,筛选下拉不会漏。
|
# 改成字典里没有的名字也允许(自定义),顺手登记,筛选下拉不会漏。
|
||||||
ensure_shops_registered(db, current, [changes["shop"]["new"]])
|
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 前面,像状态是被后一次操作改的。
|
# status_auto 排在 update 前面,像状态是被后一次操作改的。
|
||||||
record_audit(
|
record_audit(
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ import type {
|
|||||||
HuyaCollabCkRequest,
|
HuyaCollabCkRequest,
|
||||||
HuyaCollabCkStatusRequest,
|
HuyaCollabCkStatusRequest,
|
||||||
HuyaCollabFrequentGift,
|
HuyaCollabFrequentGift,
|
||||||
|
HuyaCollabGiftCatalog,
|
||||||
|
HuyaCollabGiftCatalogCreate,
|
||||||
|
HuyaCollabGiftCatalogUpdate,
|
||||||
HuyaCollabImagesRequest,
|
HuyaCollabImagesRequest,
|
||||||
HuyaCollabImportResult,
|
HuyaCollabImportResult,
|
||||||
HuyaCollabMetric,
|
HuyaCollabMetric,
|
||||||
@@ -243,6 +246,19 @@ export const huyaApi = {
|
|||||||
api.delete<{ success: boolean; name: string }, { success: boolean; name: string }>(
|
api.delete<{ success: boolean; name: string }, { success: boolean; name: string }>(
|
||||||
`/huya/collab-shops/${id}`,
|
`/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) =>
|
getCollabOrder: (id: number) =>
|
||||||
api.get<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}`),
|
api.get<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}`),
|
||||||
createCollabOrder: (data: HuyaCollabOrderCreate) =>
|
createCollabOrder: (data: HuyaCollabOrderCreate) =>
|
||||||
|
|||||||
@@ -1824,3 +1824,33 @@ export interface HuyaCollabShopUpdate {
|
|||||||
/** 改名时是否级联更新已有订单里的店铺名(默认 true) */
|
/** 改名时是否级联更新已有订单里的店铺名(默认 true) */
|
||||||
rename_orders?: boolean;
|
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,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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type HuyaCollabCkPreview,
|
type HuyaCollabCkPreview,
|
||||||
type HuyaCollabFrequentGift,
|
type HuyaCollabFrequentGift,
|
||||||
type HuyaCollabGift,
|
type HuyaCollabGift,
|
||||||
|
type HuyaCollabGiftCatalog,
|
||||||
type HuyaCollabImageSlot,
|
type HuyaCollabImageSlot,
|
||||||
type HuyaCollabMetric,
|
type HuyaCollabMetric,
|
||||||
type HuyaCollabOrder,
|
type HuyaCollabOrder,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
import HuyaCollabShopModal from '../components/HuyaCollabShopModal';
|
import HuyaCollabShopModal from '../components/HuyaCollabShopModal';
|
||||||
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
|
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
|
||||||
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
|
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
|
||||||
|
import HuyaCollabGiftModal from '../components/HuyaCollabGiftModal';
|
||||||
import { message } from '../utils/antdMessage';
|
import { message } from '../utils/antdMessage';
|
||||||
import { getErrorMessage } from '../utils/error';
|
import { getErrorMessage } from '../utils/error';
|
||||||
import { copyTextToClipboard } from '../utils/clipboard';
|
import { copyTextToClipboard } from '../utils/clipboard';
|
||||||
@@ -513,10 +515,12 @@ export default function HuyaCollabPage() {
|
|||||||
const [summary, setSummary] = useState<HuyaCollabSummary | null>(null);
|
const [summary, setSummary] = useState<HuyaCollabSummary | null>(null);
|
||||||
const [owners, setOwners] = useState<HuyaCollabOwner[]>([]);
|
const [owners, setOwners] = useState<HuyaCollabOwner[]>([]);
|
||||||
const [shops, setShops] = useState<HuyaCollabShop[]>([]);
|
const [shops, setShops] = useState<HuyaCollabShop[]>([]);
|
||||||
|
const [giftCatalog, setGiftCatalog] = useState<HuyaCollabGiftCatalog[]>([]);
|
||||||
const [giftNames, setGiftNames] = useState<string[]>([]);
|
const [giftNames, setGiftNames] = useState<string[]>([]);
|
||||||
// 「常用礼包」:台账里用得最多的几个(新建/补资料的礼包名快捷按钮,见 2026-10-18 需求)
|
// 「常用礼包」:台账里用得最多的几个(新建/补资料的礼包名快捷按钮,见 2026-10-18 需求)
|
||||||
const [frequentGifts, setFrequentGifts] = useState<HuyaCollabFrequentGift[]>([]);
|
const [frequentGifts, setFrequentGifts] = useState<HuyaCollabFrequentGift[]>([]);
|
||||||
const [shopModalOpen, setShopModalOpen] = useState(false);
|
const [shopModalOpen, setShopModalOpen] = useState(false);
|
||||||
|
const [giftModalOpen, setGiftModalOpen] = useState(false);
|
||||||
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({
|
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({
|
||||||
include_void: false,
|
include_void: false,
|
||||||
archive_scope: 'active',
|
archive_scope: 'active',
|
||||||
@@ -616,6 +620,7 @@ export default function HuyaCollabPage() {
|
|||||||
nextSummary,
|
nextSummary,
|
||||||
nextOwners,
|
nextOwners,
|
||||||
nextShops,
|
nextShops,
|
||||||
|
nextGiftCatalog,
|
||||||
nextGiftNames,
|
nextGiftNames,
|
||||||
nextFrequentGifts,
|
nextFrequentGifts,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
@@ -632,6 +637,7 @@ export default function HuyaCollabPage() {
|
|||||||
}),
|
}),
|
||||||
withMeta ? huyaApi.collabOrderOwners() : Promise.resolve(null),
|
withMeta ? huyaApi.collabOrderOwners() : Promise.resolve(null),
|
||||||
withMeta ? huyaApi.listCollabShops() : Promise.resolve(null),
|
withMeta ? huyaApi.listCollabShops() : Promise.resolve(null),
|
||||||
|
withMeta ? huyaApi.listCollabGiftCatalog() : Promise.resolve(null),
|
||||||
withMeta ? huyaApi.collabOrderGifts() : Promise.resolve(null),
|
withMeta ? huyaApi.collabOrderGifts() : Promise.resolve(null),
|
||||||
withMeta ? huyaApi.collabFrequentGifts() : Promise.resolve(null),
|
withMeta ? huyaApi.collabFrequentGifts() : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
@@ -640,6 +646,7 @@ export default function HuyaCollabPage() {
|
|||||||
setSummary(nextSummary);
|
setSummary(nextSummary);
|
||||||
if (nextOwners) setOwners(nextOwners);
|
if (nextOwners) setOwners(nextOwners);
|
||||||
if (nextShops) setShops(nextShops);
|
if (nextShops) setShops(nextShops);
|
||||||
|
if (nextGiftCatalog) setGiftCatalog(nextGiftCatalog);
|
||||||
if (nextGiftNames) setGiftNames(nextGiftNames);
|
if (nextGiftNames) setGiftNames(nextGiftNames);
|
||||||
if (nextFrequentGifts) setFrequentGifts(nextFrequentGifts);
|
if (nextFrequentGifts) setFrequentGifts(nextFrequentGifts);
|
||||||
// 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 id。
|
// 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 id。
|
||||||
@@ -657,7 +664,7 @@ export default function HuyaCollabPage() {
|
|||||||
// 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。
|
// 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。
|
||||||
const realtimeSyncEnabled = realtimeEnabled && !realtimePaused;
|
const realtimeSyncEnabled = realtimeEnabled && !realtimePaused;
|
||||||
const collabModalOpen = Boolean(
|
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) => {
|
const toggleRealtimeSync = (nextEnabled: boolean) => {
|
||||||
@@ -761,12 +768,16 @@ export default function HuyaCollabPage() {
|
|||||||
}
|
}
|
||||||
if (preview.ok && preview.has_role) {
|
if (preview.ok && preview.has_role) {
|
||||||
message.success(`CK 有效:绑定角色 ${preview.bind_game_role}`);
|
message.success(`CK 有效:绑定角色 ${preview.bind_game_role}`);
|
||||||
// 「记住的礼包」如果不在当前列表里就清掉重选;列表为空时不动它(允许手填)
|
// 「记住的礼包」如果不在实时列表、也不在标准字典里就清掉重选。
|
||||||
const options = preview.gifts.length > 0 ? preview.gifts : giftsRef.current;
|
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();
|
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);
|
createForm.setFieldValue('gift_name', undefined);
|
||||||
setGiftHint(`上次选的「${picked}」不在当前的礼包列表里,已清空,请重新点选`);
|
setGiftHint(`上次选的「${picked}」不在实时礼包或标准字典里,已清空,请重新点选`);
|
||||||
} else {
|
} else {
|
||||||
setGiftHint('');
|
setGiftHint('');
|
||||||
}
|
}
|
||||||
@@ -778,7 +789,7 @@ export default function HuyaCollabPage() {
|
|||||||
if (seq === previewSeq.current) setPreviewing(false);
|
if (seq === previewSeq.current) setPreviewing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[emptyPreview, createForm],
|
[emptyPreview, createForm, giftCatalog],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1825,33 +1836,49 @@ export default function HuyaCollabPage() {
|
|||||||
: giftOptions.length > 0
|
: giftOptions.length > 0
|
||||||
? `礼包用本地缓存(${giftOptions.length} 个${giftCacheLabel ? ` · ${giftCacheLabel}` : ''}),要更新点「强制刷新礼包」`
|
? `礼包用本地缓存(${giftOptions.length} 个${giftCacheLabel ? ` · ${giftCacheLabel}` : ''}),要更新点「强制刷新礼包」`
|
||||||
: (ckPreview?.gifts_message || '');
|
: (ckPreview?.gifts_message || '');
|
||||||
|
const hasSelectableGifts = giftOptions.length > 0
|
||||||
|
|| giftCatalog.some((gift) => gift.is_active);
|
||||||
|
|
||||||
/**
|
/** 礼包下拉:实时活动礼包 + 管理员维护的标准字典,二者按名称去重。 */
|
||||||
* 礼包下拉选项:只允许点选缓存里的礼包,不接受手输。
|
|
||||||
* 当前值是历史/已下线礼包时补一个只读选项,保证界面能显示,但依然只能从下拉里选。
|
|
||||||
*/
|
|
||||||
const giftSelectOptions = (current?: string | null) => {
|
const giftSelectOptions = (current?: string | null) => {
|
||||||
const value = String(current ?? '').trim();
|
const value = String(current ?? '').trim();
|
||||||
const options = giftOptions.map((gift) => ({
|
const seen = new Set<string>();
|
||||||
value: gift.name,
|
const options = giftOptions
|
||||||
label: [
|
.filter((gift) => {
|
||||||
gift.category_name ? `[${gift.category_name}]` : '',
|
if (!gift.name || seen.has(gift.name)) return false;
|
||||||
gift.name,
|
seen.add(gift.name);
|
||||||
gift.price ? `· ${gift.price} 积分` : '',
|
return true;
|
||||||
gift.remain_text ? `· 剩 ${gift.remain_text}` : '',
|
})
|
||||||
].filter(Boolean).join(' '),
|
.map((gift) => ({
|
||||||
}));
|
value: gift.name,
|
||||||
if (value && !giftOptions.some((gift) => gift.name === value)) {
|
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}(历史值)` });
|
options.unshift({ value, label: `${value}(历史值)` });
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 礼包名对应的积分单价;缓存里没有 / 没价格时返回 null(后端核账时还会按名称补一次)。 */
|
/** 礼包名对应的积分:实时价格优先,其次标准字典;都没有时返回 null。 */
|
||||||
const giftRequiredPoints = (name?: string | null): number | null => {
|
const giftRequiredPoints = (name?: string | null): number | null => {
|
||||||
const value = String(name ?? '').trim();
|
const value = String(name ?? '').trim();
|
||||||
if (!value) return null;
|
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)。缓存为空时整行不显示。
|
* 点一个拉不到的名字等于白点(见设计文档 §11.4)。缓存为空时整行不显示。
|
||||||
*/
|
*/
|
||||||
const frequentGiftChoices = (): HuyaCollabFrequentGift[] => {
|
const frequentGiftChoices = (): HuyaCollabFrequentGift[] => {
|
||||||
if (giftOptions.length === 0) return [];
|
const available = new Set([
|
||||||
const available = new Set(giftOptions.map((gift) => gift.name));
|
...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);
|
return frequentGifts.filter((item) => available.has(item.name)).slice(0, 6);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2107,7 +2137,7 @@ export default function HuyaCollabPage() {
|
|||||||
// 前缀单独一行、礼包名完整换行,别让单行省略号把名字吃掉
|
// 前缀单独一行、礼包名完整换行,别让单行省略号把名字吃掉
|
||||||
labelRender={() => renderGiftSelectedLabel(row.gift_name)}
|
labelRender={() => renderGiftSelectedLabel(row.gift_name)}
|
||||||
styles={{ content: { whiteSpace: 'normal', textOverflow: 'clip', lineHeight: 1.3 } }}
|
styles={{ content: { whiteSpace: 'normal', textOverflow: 'clip', lineHeight: 1.3 } }}
|
||||||
notFoundContent={giftOptions.length === 0 ? '礼包缓存为空:先在「新建」里贴 CK 拉一次' : '没有匹配的礼包'}
|
notFoundContent={hasSelectableGifts ? '没有匹配的礼包' : '实时礼包为空,标准字典也还没有配置'}
|
||||||
onChange={(next: string) => void patchOrder(row, {
|
onChange={(next: string) => void patchOrder(row, {
|
||||||
gift_name: next,
|
gift_name: next,
|
||||||
required_points: giftRequiredPoints(next),
|
required_points: giftRequiredPoints(next),
|
||||||
@@ -2528,6 +2558,9 @@ export default function HuyaCollabPage() {
|
|||||||
{canEdit && !isArchiveView && (
|
{canEdit && !isArchiveView && (
|
||||||
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>店铺管理</Button>
|
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>店铺管理</Button>
|
||||||
)}
|
)}
|
||||||
|
{canManage && !isArchiveView && (
|
||||||
|
<Button icon={<GiftOutlined />} onClick={() => setGiftModalOpen(true)}>礼包管理</Button>
|
||||||
|
)}
|
||||||
{canEdit && !isArchiveView && (
|
{canEdit && !isArchiveView && (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -2776,16 +2809,16 @@ export default function HuyaCollabPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="gift_name" label="礼包名"
|
<Form.Item name="gift_name" label="礼包名"
|
||||||
tooltip="只接受礼包缓存里的点选结果,可输入关键字搜索;不接受手工输入">
|
tooltip="从实时活动礼包或管理员维护的标准字典里点选,可输入关键字搜索">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
showSearch
|
showSearch
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
placeholder="输入关键字搜索,点选礼包"
|
placeholder="输入关键字搜索,点选礼包"
|
||||||
options={giftSelectOptions(detail.gift_name)}
|
options={giftSelectOptions(detail.gift_name)}
|
||||||
notFoundContent={giftOptions.length === 0
|
notFoundContent={hasSelectableGifts
|
||||||
? '礼包缓存为空:先在「新建」里贴 CK 拉一次'
|
? '没有匹配的礼包'
|
||||||
: '没有匹配的礼包'}
|
: '实时礼包为空,标准字典也还没有配置'}
|
||||||
onChange={(value?: string) => {
|
onChange={(value?: string) => {
|
||||||
detailForm.setFieldValue('required_points', giftRequiredPoints(value));
|
detailForm.setFieldValue('required_points', giftRequiredPoints(value));
|
||||||
}}
|
}}
|
||||||
@@ -3117,20 +3150,27 @@ export default function HuyaCollabPage() {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name="gift_name"
|
name="gift_name"
|
||||||
label="礼包名(用户购买的物品,可稍后补)"
|
label="礼包名(用户购买的物品,可稍后补)"
|
||||||
tooltip="默认空白,不会自动带上次选的礼包(2026-10-18)。只接受礼包缓存里的点选结果,可输入关键字搜索;不选也能先建单,之后点「补资料」再选。"
|
tooltip="默认空白,不会自动带上次选的礼包。可从实时活动礼包或管理员维护的标准字典里点选;不选也能先建单,之后点「补资料」再选。"
|
||||||
extra={giftHint || (giftsCachedAt && giftOptions.length > 0
|
extra={giftHint || [
|
||||||
? `礼包列表缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
|
giftsCachedAt && giftOptions.length > 0
|
||||||
: undefined)}
|
? `实时礼包缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
|
||||||
|
: '',
|
||||||
|
giftCatalog.length > 0
|
||||||
|
? `标准字典 ${giftCatalog.filter((gift) => gift.is_active).length} 个可用`
|
||||||
|
: '',
|
||||||
|
].filter(Boolean).join(';') || undefined}
|
||||||
validateStatus={giftHint ? 'warning' : undefined}
|
validateStatus={giftHint ? 'warning' : undefined}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
showSearch
|
showSearch
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
placeholder={giftOptions.length > 0 ? '输入关键字搜索,点选礼包' : '礼包缓存为空:先贴 CK 拉取'}
|
placeholder={hasSelectableGifts ? '输入关键字搜索,点选礼包' : '请先在上方「礼包管理」配置标准礼包'}
|
||||||
options={giftSelectOptions(createGiftValue)}
|
options={giftSelectOptions(createGiftValue)}
|
||||||
disabled={giftOptions.length === 0 && !createGiftValue}
|
disabled={!hasSelectableGifts && !createGiftValue}
|
||||||
notFoundContent={giftOptions.length === 0 ? '先在左侧贴一份有效 CK 拉取礼包列表' : '没有匹配的礼包'}
|
notFoundContent={hasSelectableGifts
|
||||||
|
? '没有匹配的礼包'
|
||||||
|
: '实时礼包为空,标准字典也还没有配置'}
|
||||||
onChange={(value?: string) => {
|
onChange={(value?: string) => {
|
||||||
// 选了就清掉「上次那个礼包不在本次列表里」的提示,并带出积分价格
|
// 选了就清掉「上次那个礼包不在本次列表里」的提示,并带出积分价格
|
||||||
setGiftHint('');
|
setGiftHint('');
|
||||||
@@ -3256,17 +3296,17 @@ export default function HuyaCollabPage() {
|
|||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
<Form.Item name="gift_name" label="礼包名"
|
<Form.Item name="gift_name" label="礼包名"
|
||||||
tooltip="只接受礼包缓存里的点选结果,可输入关键字搜索;不接受手工输入">
|
tooltip="从实时活动礼包或管理员维护的标准字典里点选,可输入关键字搜索">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
showSearch
|
showSearch
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
placeholder={giftOptions.length > 0 ? '输入关键字搜索,点选礼包' : '礼包缓存为空:先在「新建」里贴 CK 拉一次'}
|
placeholder={hasSelectableGifts ? '输入关键字搜索,点选礼包' : '请先让管理员配置标准礼包'}
|
||||||
options={giftSelectOptions(supplementGiftValue ?? supplementTarget.gift_name)}
|
options={giftSelectOptions(supplementGiftValue ?? supplementTarget.gift_name)}
|
||||||
disabled={giftOptions.length === 0 && !supplementGiftValue}
|
disabled={!hasSelectableGifts && !supplementGiftValue}
|
||||||
notFoundContent={giftOptions.length === 0
|
notFoundContent={hasSelectableGifts
|
||||||
? '礼包缓存为空:先在「新建」里贴 CK 拉一次'
|
? '没有匹配的礼包'
|
||||||
: '没有匹配的礼包'}
|
: '实时礼包为空,标准字典也还没有配置'}
|
||||||
onChange={(value?: string) => {
|
onChange={(value?: string) => {
|
||||||
supplementForm.setFieldValue('required_points', giftRequiredPoints(value));
|
supplementForm.setFieldValue('required_points', giftRequiredPoints(value));
|
||||||
}}
|
}}
|
||||||
@@ -3587,6 +3627,13 @@ export default function HuyaCollabPage() {
|
|||||||
onClose={() => setShopModalOpen(false)}
|
onClose={() => setShopModalOpen(false)}
|
||||||
onChanged={() => void load()}
|
onChanged={() => void load()}
|
||||||
/>
|
/>
|
||||||
|
<HuyaCollabGiftModal
|
||||||
|
open={giftModalOpen}
|
||||||
|
gifts={giftCatalog}
|
||||||
|
canManage={canManage}
|
||||||
|
onClose={() => setGiftModalOpen(false)}
|
||||||
|
onChanged={() => void load()}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user