feat(douyu): 增加协作单礼包字典与权限控制
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -127,6 +127,134 @@ class TestDouyuCollabOrders:
|
||||
assert order.status == collab.STATUS_TO_RECHARGE
|
||||
DouyuCollabOrderOut.model_validate(collab.serialize_one(self.session, order))
|
||||
|
||||
def test_manual_gift_without_goods_snapshot_keeps_name_and_points(self):
|
||||
order = collab.create_order(
|
||||
self.session,
|
||||
self.admin,
|
||||
order_no="DY-MANUAL-001",
|
||||
shop="店铺A",
|
||||
account_username="douyu-account-a",
|
||||
gift_name="套装-暗影哥特",
|
||||
required_points=888,
|
||||
home_images=["collab/home-manual.png"],
|
||||
client=FakeClient(),
|
||||
)
|
||||
assert order.gift_name == "套装-暗影哥特"
|
||||
assert order.commodity_id == ""
|
||||
assert order.required_points == 888
|
||||
assert order.status == collab.STATUS_TO_RECHARGE
|
||||
|
||||
collab.update_order(
|
||||
self.session,
|
||||
self.admin,
|
||||
order,
|
||||
{
|
||||
"gift_name": "套装-暗影哥特",
|
||||
"commodity_id": "",
|
||||
"required_points": 888,
|
||||
"remark": "接口未返回商品,手动填写",
|
||||
},
|
||||
expected_version=order.version,
|
||||
)
|
||||
payload = collab.serialize_one(self.session, order)
|
||||
assert payload["gift_name"] == "套装-暗影哥特"
|
||||
assert payload["commodity_id"] == ""
|
||||
assert payload["required_points"] == 888
|
||||
DouyuCollabOrderOut.model_validate(payload)
|
||||
|
||||
def test_gift_catalog_crud_rename_cascades_and_delete_guard(self):
|
||||
gift = collab.create_gift(
|
||||
self.session,
|
||||
self.admin,
|
||||
name="套装-暗影哥特",
|
||||
points=888,
|
||||
remark="接口未返回商品",
|
||||
)
|
||||
assert gift.points == 888
|
||||
with pytest.raises(collab.CollabError) as duplicated:
|
||||
collab.create_gift(
|
||||
self.session,
|
||||
self.admin,
|
||||
name="套装-暗影哥特",
|
||||
points=900,
|
||||
)
|
||||
assert duplicated.value.code == "gift_exists"
|
||||
|
||||
order = collab.create_order(
|
||||
self.session,
|
||||
self.admin,
|
||||
order_no="DY-GIFT-001",
|
||||
shop="店铺A",
|
||||
account_username="douyu-account-a",
|
||||
gift_name=gift.name,
|
||||
required_points=None,
|
||||
home_images=["collab/home-gift.png"],
|
||||
client=FakeClient(),
|
||||
)
|
||||
assert order.gift_name == "套装-暗影哥特"
|
||||
assert order.commodity_id == ""
|
||||
assert order.required_points == 888
|
||||
|
||||
collab.update_gift(
|
||||
self.session,
|
||||
self.admin,
|
||||
gift,
|
||||
{"name": "套装-暗影哥特-返场", "points": 999},
|
||||
)
|
||||
self.session.refresh(order)
|
||||
assert order.gift_name == "套装-暗影哥特-返场"
|
||||
assert order.required_points == 888
|
||||
|
||||
with pytest.raises(collab.CollabError) as in_use:
|
||||
collab.delete_gift(self.session, self.admin, gift)
|
||||
assert in_use.value.code == "gift_in_use"
|
||||
|
||||
spare = collab.create_gift(
|
||||
self.session,
|
||||
self.admin,
|
||||
name="套装-星河流浪者",
|
||||
points=1680,
|
||||
)
|
||||
assert [
|
||||
row.name for row in collab.list_gifts(self.session, active_only=True)
|
||||
] == [
|
||||
"套装-暗影哥特-返场",
|
||||
"套装-星河流浪者",
|
||||
]
|
||||
collab.delete_gift(self.session, self.admin, spare)
|
||||
assert [row.name for row in collab.list_gifts(self.session)] == [
|
||||
"套装-暗影哥特-返场"
|
||||
]
|
||||
|
||||
collab.create_gift(
|
||||
self.session,
|
||||
self.admin,
|
||||
name="礼包A",
|
||||
points=999,
|
||||
)
|
||||
api_order = collab.create_order(
|
||||
self.session,
|
||||
self.admin,
|
||||
order_no="DY-GIFT-API-001",
|
||||
shop="店铺A",
|
||||
account_username="douyu-account-a",
|
||||
gift_name="礼包A",
|
||||
required_points=None,
|
||||
home_images=["collab/home-gift-api.png"],
|
||||
client=FakeClient(),
|
||||
)
|
||||
# 接口恢复返回商品后以接口积分为准,字典只做兜底。
|
||||
assert api_order.required_points == 100
|
||||
assert api_order.commodity_id == "gift-a"
|
||||
|
||||
def test_gift_catalog_write_requires_manage_permission(self):
|
||||
from web.backend.permissions import get_role_permissions
|
||||
|
||||
support = get_role_permissions("support")
|
||||
assert "douyu:collab:view" in support
|
||||
assert "douyu:collab:manage" not in support
|
||||
assert "douyu:collab:manage" in get_role_permissions("operation")
|
||||
|
||||
def test_same_account_allows_multiple_orders(self):
|
||||
collab.create_order(
|
||||
self.session,
|
||||
@@ -680,3 +808,9 @@ class TestDouyuCollabOrders:
|
||||
assert "/api/douyu/collab-shops/{shop_id}" in paths
|
||||
assert "put" in paths["/api/douyu/collab-shops/{shop_id}"]
|
||||
assert "delete" in paths["/api/douyu/collab-shops/{shop_id}"]
|
||||
assert "/api/douyu/collab-gifts" in paths
|
||||
assert "get" in paths["/api/douyu/collab-gifts"]
|
||||
assert "post" in paths["/api/douyu/collab-gifts"]
|
||||
assert "/api/douyu/collab-gifts/{gift_id}" in paths
|
||||
assert "put" in paths["/api/douyu/collab-gifts/{gift_id}"]
|
||||
assert "delete" in paths["/api/douyu/collab-gifts/{gift_id}"]
|
||||
|
||||
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = TESTS_DIR.parent
|
||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||
|
||||
HEAD_REVISION = "20261106_0065"
|
||||
HEAD_REVISION = "20261106_0066"
|
||||
|
||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||
EXTRA_INDEXES = (
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
"""斗鱼外部单协作台账(一行 = 一单 = 一个内部斗鱼账号)。
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1768,6 +1768,33 @@ class DouyuCollabShopOut(BaseModel):
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class DouyuCollabGiftCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=128)
|
||||
points: int = Field(..., ge=0)
|
||||
remark: str = Field("", max_length=255)
|
||||
|
||||
|
||||
class DouyuCollabGiftUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=128)
|
||||
points: int | None = Field(None, ge=0)
|
||||
sort_order: int | None = None
|
||||
is_active: bool | None = None
|
||||
remark: str | None = Field(None, max_length=255)
|
||||
|
||||
|
||||
class DouyuCollabGiftOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
points: int = 0
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
remark: str = ""
|
||||
created_by: int | None = None
|
||||
updated_by: int | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class DouyuCollabGoodsOut(BaseModel):
|
||||
commodity_id: str = ""
|
||||
name: str = ""
|
||||
|
||||
@@ -25,6 +25,7 @@ from core.douyu import DouyuActivityClient
|
||||
from ..models import (
|
||||
Account,
|
||||
AuditLog,
|
||||
DouyuCollabGift,
|
||||
DouyuCollabShop,
|
||||
DouyuConfig,
|
||||
DouyuExternalOrder,
|
||||
@@ -474,6 +475,18 @@ def _resolve_gift(
|
||||
cid = _text(row.commodity_id, max_length=64)
|
||||
if points is None:
|
||||
points = _to_int(row.score)
|
||||
if points is None and name:
|
||||
manual = (
|
||||
db.query(DouyuCollabGift).filter(DouyuCollabGift.name == name).first()
|
||||
)
|
||||
if manual is not None:
|
||||
points = int(manual.points or 0)
|
||||
return name, cid, points
|
||||
if not cid and name:
|
||||
# 接口暂时不返回商品时,管理员配置的礼包名称和积分仍然可用。
|
||||
manual = db.query(DouyuCollabGift).filter(DouyuCollabGift.name == name).first()
|
||||
if manual is not None:
|
||||
return name, "", points if points is not None else int(manual.points or 0)
|
||||
return name, cid, points
|
||||
|
||||
|
||||
@@ -548,6 +561,7 @@ def create_order(
|
||||
raise CollabError("订单号已存在", status_code=409, code="order_no_exists")
|
||||
|
||||
images = normalize_images(home_images)
|
||||
explicit_points = _to_int(required_points)
|
||||
name, cid, points = _resolve_gift(
|
||||
db,
|
||||
gift_name=gift_name,
|
||||
@@ -591,18 +605,23 @@ def create_order(
|
||||
with_goods=True,
|
||||
)
|
||||
apply_probe_snapshot(order, account, probe)
|
||||
if points is None and name:
|
||||
if explicit_points is None and name:
|
||||
live = next(
|
||||
(
|
||||
item
|
||||
for item in (probe.get("goods") or [])
|
||||
if str(item.get("commodityName") or "") == name
|
||||
if str(item.get("name") or item.get("commodityName") or "")
|
||||
== name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if live is not None:
|
||||
points = _to_int(live.get("score"))
|
||||
order.required_points = points
|
||||
live_cid = _text(live.get("commodity_id"), max_length=64)
|
||||
if live_cid:
|
||||
order.commodity_id = live_cid
|
||||
live_points = _to_int(live.get("score"))
|
||||
if live_points is not None:
|
||||
order.required_points = live_points
|
||||
elif account is not None:
|
||||
# 草稿不联网探针,但既然用户名已存在,先把内部账号关联上,
|
||||
# 后续补资料时不用再靠“用户名变化”触发解析。
|
||||
@@ -1400,6 +1419,148 @@ def delete_shop(db: Session, current: User, shop: DouyuCollabShop) -> dict[str,
|
||||
return {"success": True, "name": shop_name}
|
||||
|
||||
|
||||
def list_gifts(db: Session, *, active_only: bool = False) -> list[DouyuCollabGift]:
|
||||
query = db.query(DouyuCollabGift)
|
||||
if active_only:
|
||||
query = query.filter(DouyuCollabGift.is_active.is_(True))
|
||||
return query.order_by(
|
||||
DouyuCollabGift.sort_order.asc(), DouyuCollabGift.id.asc()
|
||||
).all()
|
||||
|
||||
|
||||
def get_gift(db: Session, gift_id: int) -> DouyuCollabGift:
|
||||
gift = db.query(DouyuCollabGift).filter(DouyuCollabGift.id == gift_id).first()
|
||||
if gift is None:
|
||||
raise CollabError("礼包不存在", status_code=404, code="gift_not_found")
|
||||
return gift
|
||||
|
||||
|
||||
def create_gift(
|
||||
db: Session,
|
||||
current: User,
|
||||
*,
|
||||
name: str,
|
||||
points: Any,
|
||||
remark: str = "",
|
||||
) -> DouyuCollabGift:
|
||||
clean = _text(name, max_length=128)
|
||||
if not clean:
|
||||
raise CollabError("礼包名不能为空", code="gift_name_required")
|
||||
clean_points = _to_int(points)
|
||||
if clean_points is None or clean_points < 0:
|
||||
raise CollabError("所需积分不能为空且不能小于 0", code="gift_points_invalid")
|
||||
exists = db.query(DouyuCollabGift).filter(DouyuCollabGift.name == clean).first()
|
||||
if exists:
|
||||
raise CollabError("礼包已存在", status_code=409, code="gift_exists")
|
||||
gift = DouyuCollabGift(
|
||||
name=clean,
|
||||
points=clean_points,
|
||||
remark=_text(remark, max_length=255),
|
||||
created_by=current.id,
|
||||
updated_by=current.id,
|
||||
)
|
||||
db.add(gift)
|
||||
db.commit()
|
||||
db.refresh(gift)
|
||||
return gift
|
||||
|
||||
|
||||
def update_gift(
|
||||
db: Session,
|
||||
current: User,
|
||||
gift: DouyuCollabGift,
|
||||
payload: Mapping[str, Any],
|
||||
) -> DouyuCollabGift:
|
||||
old_name = gift.name
|
||||
old = {
|
||||
"name": gift.name,
|
||||
"points": gift.points,
|
||||
"sort_order": gift.sort_order,
|
||||
"is_active": gift.is_active,
|
||||
"remark": gift.remark,
|
||||
}
|
||||
if "name" in payload:
|
||||
name = _text(payload.get("name"), max_length=128)
|
||||
if not name:
|
||||
raise CollabError("礼包名不能为空", code="gift_name_required")
|
||||
duplicate = (
|
||||
db.query(DouyuCollabGift.id)
|
||||
.filter(
|
||||
DouyuCollabGift.name == name,
|
||||
DouyuCollabGift.id != gift.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if duplicate:
|
||||
raise CollabError("礼包已存在", status_code=409, code="gift_exists")
|
||||
gift.name = name
|
||||
if "points" in payload:
|
||||
points = _to_int(payload.get("points"))
|
||||
if points is None or points < 0:
|
||||
raise CollabError(
|
||||
"所需积分不能为空且不能小于 0", code="gift_points_invalid"
|
||||
)
|
||||
gift.points = points
|
||||
if "sort_order" in payload:
|
||||
gift.sort_order = _to_int(payload.get("sort_order")) or 0
|
||||
if "is_active" in payload:
|
||||
gift.is_active = bool(payload.get("is_active"))
|
||||
if "remark" in payload:
|
||||
gift.remark = _text(payload.get("remark"), max_length=255)
|
||||
gift.updated_by = current.id
|
||||
if gift.name != old_name:
|
||||
# 名称是订单里的展示/匹配口径,改名要同步历史订单;
|
||||
# 积分是下单时的快照,更新字典不回填历史订单。
|
||||
db.query(DouyuExternalOrder).filter(
|
||||
DouyuExternalOrder.gift_name == old_name
|
||||
).update({DouyuExternalOrder.gift_name: gift.name}, synchronize_session=False)
|
||||
record_audit(
|
||||
db,
|
||||
current,
|
||||
action="douyu:collab:gift_update",
|
||||
target=f"douyu_collab_gift:{gift.id}",
|
||||
detail={
|
||||
"old": old,
|
||||
"new": {
|
||||
"name": gift.name,
|
||||
"points": gift.points,
|
||||
"sort_order": gift.sort_order,
|
||||
"is_active": gift.is_active,
|
||||
"remark": gift.remark,
|
||||
},
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(gift)
|
||||
return gift
|
||||
|
||||
|
||||
def delete_gift(db: Session, current: User, gift: DouyuCollabGift) -> dict[str, Any]:
|
||||
gift_name = gift.name
|
||||
gift_points = gift.points
|
||||
used = (
|
||||
db.query(func.count(DouyuExternalOrder.id))
|
||||
.filter(DouyuExternalOrder.gift_name == gift_name)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
if used:
|
||||
raise CollabError(
|
||||
f"还有 {used} 个协作单使用「{gift_name}」,请先停用或改名",
|
||||
code="gift_in_use",
|
||||
)
|
||||
record_audit(
|
||||
db,
|
||||
current,
|
||||
action="douyu:collab:gift_delete",
|
||||
target=f"douyu_collab_gift:{gift.id}",
|
||||
detail={"name": gift_name, "points": gift_points},
|
||||
)
|
||||
db.delete(gift)
|
||||
db.commit()
|
||||
return {"success": True, "name": gift_name}
|
||||
|
||||
|
||||
def list_gift_names(db: Session) -> list[str]:
|
||||
rows = (
|
||||
db.query(DouyuExternalOrder.gift_name)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
@@ -1328,21 +1361,43 @@ export default function DouyuCollabPage() {
|
||||
];
|
||||
|
||||
const goodsPool = lookup?.goods?.length ? lookup.goods : goods;
|
||||
const giftOptions = goodsOptions(goodsPool, Boolean(lookup?.goods?.length)).map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
disabled: item.disabled,
|
||||
}));
|
||||
if (
|
||||
form.commodity_id &&
|
||||
!giftOptions.some((item) => item.value === form.commodity_id)
|
||||
) {
|
||||
giftOptions.unshift({
|
||||
value: form.commodity_id,
|
||||
label: `${form.gift_name || form.commodity_id} · 当前已选`,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
const giftNameOptions = (() => {
|
||||
const options: Array<{ value: string; label: string; disabled: boolean }> = [];
|
||||
const seen = new Set<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 +1692,11 @@ export default function DouyuCollabPage() {
|
||||
刷新活动商品
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button icon={<GiftOutlined />} onClick={() => setGiftModalOpen(true)}>
|
||||
礼包管理
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>
|
||||
店铺管理
|
||||
@@ -1689,6 +1749,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 +1882,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 +1926,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>
|
||||
|
||||
Reference in New Issue
Block a user