feat(douyu): 和平小店扭蛋碎片查询 + 深色模式修复 + 任务状态 WS 即时推送
- 碎片: 新增 query_xpd_fragments 任务, 复用 e语言 gamecoin 接口取扭蛋币 jb2, 账号已存角色回退(规避 Livelink 风控), accounts.xpd_fragments 字段与迁移 - 列表: 和平小店移除鱼翅/限制兑换/换绑时间列, 新增扭蛋碎片列; 操作区硬编码白底改用 theme token, 深色模式适配(三个手册共用) - 轮询: 任务状态经 WS(level=task)即时推送(二维码/绑定进度即时展示), pending/running/终态全覆盖, REST/WS 载荷统一 douyu_task_payload, raw 快照递归剥离, HTTP 轮询降频 5s/30s 兜底
This commit is contained in:
@@ -1105,3 +1105,44 @@ class DouyuActivityClient:
|
|||||||
)
|
)
|
||||||
info = self._xpd_parse_var(response.text, "banlanceInfo")
|
info = self._xpd_parse_var(response.text, "banlanceInfo")
|
||||||
return {"balance": self._xpd_int(info.get("balance")), "raw": info}
|
return {"balance": self._xpd_int(info.get("balance")), "raw": info}
|
||||||
|
|
||||||
|
def xpd_fragments(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
embed_query: dict[str, str],
|
||||||
|
act_id: str,
|
||||||
|
openid: str,
|
||||||
|
roleid: str,
|
||||||
|
plat: str,
|
||||||
|
areaid: str = "1",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""查询和平小店角色扭蛋碎片数量(gamecoin jb2)。"""
|
||||||
|
params = {
|
||||||
|
"_jsvar": "jbInfos",
|
||||||
|
"_service": "pay.idip.gamecoin.get",
|
||||||
|
"_app_id": "2123",
|
||||||
|
"acctype": "ttpp",
|
||||||
|
"areaid": str(areaid),
|
||||||
|
"eventid": "",
|
||||||
|
"interwork": "0",
|
||||||
|
"openid": openid,
|
||||||
|
"openkey": "openkey",
|
||||||
|
"partition": "0",
|
||||||
|
"pay_token": "",
|
||||||
|
"plat": str(plat),
|
||||||
|
"plat_pc": "0",
|
||||||
|
"roleid": roleid,
|
||||||
|
"_biz_code": "cjm",
|
||||||
|
"_act_id": act_id,
|
||||||
|
"coin_type": "1",
|
||||||
|
"_sid": "6",
|
||||||
|
}
|
||||||
|
response = self._request(
|
||||||
|
"get",
|
||||||
|
self.XPD_BALANCE_API,
|
||||||
|
source="查询小店扭蛋碎片",
|
||||||
|
params=params,
|
||||||
|
headers=self._xpd_daoju_headers(),
|
||||||
|
)
|
||||||
|
info = self._xpd_parse_var(response.text, "jbInfos")
|
||||||
|
return {"fragments": self._xpd_int(info.get("jb2")), "raw": info}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""斗鱼和平小店账号新增扭蛋碎片字段
|
||||||
|
|
||||||
|
Revision ID: 20260807_0017
|
||||||
|
Revises: 20260806_0016
|
||||||
|
Create Date: 2026-08-07
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260807_0017"
|
||||||
|
down_revision: Union[str, None] = "20260806_0016"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not sa.inspect(bind).has_table("accounts"):
|
||||||
|
return
|
||||||
|
columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")}
|
||||||
|
if "xpd_fragments" not in columns:
|
||||||
|
op.add_column("accounts", sa.Column("xpd_fragments", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not sa.inspect(bind).has_table("accounts"):
|
||||||
|
return
|
||||||
|
columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")}
|
||||||
|
if "xpd_fragments" in columns:
|
||||||
|
op.drop_column("accounts", "xpd_fragments")
|
||||||
@@ -76,6 +76,7 @@ class Account(Base):
|
|||||||
xpd_plat_id = Column(Integer, nullable=True)
|
xpd_plat_id = Column(Integer, nullable=True)
|
||||||
xpd_area_id = Column(Integer, nullable=True)
|
xpd_area_id = Column(Integer, nullable=True)
|
||||||
xpd_balance = Column(Integer, nullable=True)
|
xpd_balance = Column(Integer, nullable=True)
|
||||||
|
xpd_fragments = Column(Integer, nullable=True)
|
||||||
xpd_bind_status = Column(String(32), default="")
|
xpd_bind_status = Column(String(32), default="")
|
||||||
created_at = Column(DateTime, default=_utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from ..services.douyu_service import (
|
|||||||
cookie_account_ids_query,
|
cookie_account_ids_query,
|
||||||
create_douyu_planned_tasks,
|
create_douyu_planned_tasks,
|
||||||
douyu_config_value,
|
douyu_config_value,
|
||||||
|
douyu_task_payload,
|
||||||
ensure_douyu_config,
|
ensure_douyu_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,104 +121,15 @@ def _account_out(account: Account) -> DouyuTaskAccountOut:
|
|||||||
xpd_plat_id=account.xpd_plat_id,
|
xpd_plat_id=account.xpd_plat_id,
|
||||||
xpd_area_id=account.xpd_area_id,
|
xpd_area_id=account.xpd_area_id,
|
||||||
xpd_balance=account.xpd_balance,
|
xpd_balance=account.xpd_balance,
|
||||||
|
xpd_fragments=account.xpd_fragments,
|
||||||
xpd_bind_status=account.xpd_bind_status or "",
|
xpd_bind_status=account.xpd_bind_status or "",
|
||||||
assigned_to=account.assigned_to,
|
assigned_to=account.assigned_to,
|
||||||
assigned_username=account.assigned_user.username if account.assigned_user else None,
|
assigned_username=account.assigned_user.username if account.assigned_user else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _slim_goods(goods: object) -> object:
|
|
||||||
"""保留兑换图片和列表展示需要的商品小字段。"""
|
|
||||||
if not isinstance(goods, dict):
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
key: value
|
|
||||||
for key, value in goods.items()
|
|
||||||
if key in {"commodityId", "commodity_id", "commodityName", "name", "webPic", "pic", "score", "status"}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _slim_limited_goods(goods: object) -> list[dict[str, object]]:
|
|
||||||
"""限兑列表只返回前几个商品名,避免任务列表携带完整原始数组。"""
|
|
||||||
if not isinstance(goods, list):
|
|
||||||
return []
|
|
||||||
result: list[dict[str, object]] = []
|
|
||||||
for item in goods[:5]:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
result.append({
|
|
||||||
key: value
|
|
||||||
for key, value in item.items()
|
|
||||||
if key in {"commodityId", "commodity_id", "commodityName", "name"}
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_task_result(result: dict | None, task_type: str, *, include_detail: bool = False) -> dict | None:
|
|
||||||
"""列表接口剥离原始快照/大数组;详情接口保留完整 result。"""
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
return result
|
|
||||||
if include_detail:
|
|
||||||
return result
|
|
||||||
|
|
||||||
data = dict(result)
|
|
||||||
for key in (
|
|
||||||
"raw",
|
|
||||||
"bind_info",
|
|
||||||
"before_bind_info",
|
|
||||||
"bind_candidates",
|
|
||||||
"cooldown_bind_info",
|
|
||||||
"after_bind_info",
|
|
||||||
"activity_bind_snapshot",
|
|
||||||
"points_query",
|
|
||||||
"exchange_balance_query",
|
|
||||||
"query_act_aliases",
|
|
||||||
"records",
|
|
||||||
):
|
|
||||||
data.pop(key, None)
|
|
||||||
|
|
||||||
# 和平小店任务 result 内嵌 role(含 gameOpenId 等),列表接口剥离其原始快照
|
|
||||||
role = data.get("role")
|
|
||||||
if isinstance(role, dict):
|
|
||||||
data["role"] = {key: value for key, value in role.items() if key != "raw"}
|
|
||||||
|
|
||||||
goods = data.get("goods")
|
|
||||||
if isinstance(goods, list):
|
|
||||||
data.pop("goods", None)
|
|
||||||
elif isinstance(goods, dict):
|
|
||||||
data["goods"] = _slim_goods(goods)
|
|
||||||
|
|
||||||
if "limited_goods" in data:
|
|
||||||
data["limited_goods"] = _slim_limited_goods(data.get("limited_goods"))
|
|
||||||
|
|
||||||
if task_type not in {"get_bind_qr", "prepare_esports_bind", "get_esports_bind_qr", "get_xpd_bind_qr"}:
|
|
||||||
data.pop("url", None)
|
|
||||||
if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}:
|
|
||||||
data.pop("pay_url", None)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _task_out(task: DouyuTask, *, include_detail: bool = False) -> DouyuTaskOut:
|
def _task_out(task: DouyuTask, *, include_detail: bool = False) -> DouyuTaskOut:
|
||||||
account = task.account
|
return DouyuTaskOut(**douyu_task_payload(task, include_detail=include_detail))
|
||||||
return DouyuTaskOut(
|
|
||||||
id=task.id,
|
|
||||||
batch_id=task.batch_id,
|
|
||||||
account_id=task.account_id,
|
|
||||||
account_username=account.username if account else "",
|
|
||||||
account_uid=account.uid if account else "",
|
|
||||||
account_nickname=account.nickname if account else "",
|
|
||||||
task_type=task.task_type,
|
|
||||||
status=task.status or "",
|
|
||||||
message=task.message or "",
|
|
||||||
result=_sanitize_task_result(
|
|
||||||
task.result if isinstance(task.result, dict) else None,
|
|
||||||
task.task_type or "",
|
|
||||||
include_detail=include_detail,
|
|
||||||
),
|
|
||||||
created_by=task.created_by,
|
|
||||||
created_at=task.created_at,
|
|
||||||
finished_at=task.finished_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _config_out(config: DouyuConfig) -> DouyuConfigOut:
|
def _config_out(config: DouyuConfig) -> DouyuConfigOut:
|
||||||
|
|||||||
@@ -704,6 +704,7 @@ class DouyuTaskAccountOut(BaseModel):
|
|||||||
xpd_plat_id: Optional[int] = None
|
xpd_plat_id: Optional[int] = None
|
||||||
xpd_area_id: Optional[int] = None
|
xpd_area_id: Optional[int] = None
|
||||||
xpd_balance: Optional[int] = None
|
xpd_balance: Optional[int] = None
|
||||||
|
xpd_fragments: Optional[int] = None
|
||||||
xpd_bind_status: str = ""
|
xpd_bind_status: str = ""
|
||||||
assigned_to: Optional[int] = None
|
assigned_to: Optional[int] = None
|
||||||
assigned_username: Optional[str] = None
|
assigned_username: Optional[str] = None
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .douyu_service import (
|
|||||||
douyu_config_value,
|
douyu_config_value,
|
||||||
ensure_douyu_config,
|
ensure_douyu_config,
|
||||||
latest_success_cookie,
|
latest_success_cookie,
|
||||||
|
douyu_task_payload,
|
||||||
update_account_profile_from_cookie,
|
update_account_profile_from_cookie,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -345,6 +346,22 @@ class DouyuBatchRunner:
|
|||||||
return ""
|
return ""
|
||||||
return f"{role_name}({channel})" if channel else role_name
|
return f"{role_name}({channel})" if channel else role_name
|
||||||
|
|
||||||
|
def _push_task_event(self, task: DouyuTask) -> None:
|
||||||
|
"""向批次 WS 推送任务状态事件(level=task),前端即时更新不依赖轮询。"""
|
||||||
|
if not self.log_queue or not self.loop:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = douyu_task_payload(task)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[douyu] 推送任务状态失败: task_id={}", task.id)
|
||||||
|
return
|
||||||
|
event = {
|
||||||
|
"level": "task",
|
||||||
|
"message": "",
|
||||||
|
"task": payload,
|
||||||
|
}
|
||||||
|
asyncio.run_coroutine_threadsafe(self.log_queue.put(event), self.loop)
|
||||||
|
|
||||||
def _mark_task(
|
def _mark_task(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -359,6 +376,7 @@ class DouyuBatchRunner:
|
|||||||
task.result = result
|
task.result = result
|
||||||
task.finished_at = datetime.now(timezone.utc)
|
task.finished_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
self._push_task_event(task)
|
||||||
|
|
||||||
def _update_task_progress(
|
def _update_task_progress(
|
||||||
self,
|
self,
|
||||||
@@ -373,6 +391,7 @@ class DouyuBatchRunner:
|
|||||||
if result is not None:
|
if result is not None:
|
||||||
task.result = result
|
task.result = result
|
||||||
db.commit()
|
db.commit()
|
||||||
|
self._push_task_event(task)
|
||||||
|
|
||||||
def _upsert_goods(self, db: Session, goods: list[dict]) -> None:
|
def _upsert_goods(self, db: Session, goods: list[dict]) -> None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -1334,6 +1353,67 @@ class DouyuBatchRunner:
|
|||||||
{"balance": balance, "role": role, "area_id": area_id},
|
{"balance": balance, "role": role, "area_id": area_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _execute_query_xpd_fragments(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""查询和平小店扭蛋碎片数量。
|
||||||
|
|
||||||
|
优先现查角色;getrole 受限(Livelink 风控/失效)时回退账号已存角色,
|
||||||
|
保证已绑定账号仍可查询。
|
||||||
|
"""
|
||||||
|
client = self._client(cookie)
|
||||||
|
act_id = str(config["xpd_act_id"])
|
||||||
|
embed_query: dict = {}
|
||||||
|
openid = str(account.xpd_openid or "")
|
||||||
|
roleid = str(account.xpd_role_id or "")
|
||||||
|
plat = str(account.xpd_plat_id or "1")
|
||||||
|
areaid = str(account.xpd_area_id or 1)
|
||||||
|
role: dict = {}
|
||||||
|
try:
|
||||||
|
ctx = self._xpd_role_context(client, config)
|
||||||
|
embed_query = ctx["embed"]["query"]
|
||||||
|
role = ctx["role"] if isinstance(ctx.get("role"), dict) else {}
|
||||||
|
if role.get("role_id"):
|
||||||
|
role_area = self._xpd_area_id(role, account)
|
||||||
|
openid = str(role.get("game_open_id") or "") or openid
|
||||||
|
roleid = str(role.get("role_id") or "") or roleid
|
||||||
|
plat = str(role.get("plat_id") or "1") or plat
|
||||||
|
areaid = str(role_area) or areaid
|
||||||
|
self._apply_xpd_role_to_account(account, role, role_area)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not openid or not roleid:
|
||||||
|
self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定")
|
||||||
|
return
|
||||||
|
result = client.xpd_fragments(
|
||||||
|
embed_query=embed_query,
|
||||||
|
act_id=act_id,
|
||||||
|
openid=openid,
|
||||||
|
roleid=roleid,
|
||||||
|
plat=plat,
|
||||||
|
areaid=areaid,
|
||||||
|
)
|
||||||
|
fragments = result.get("fragments")
|
||||||
|
account.xpd_fragments = fragments
|
||||||
|
account.xpd_bind_status = "xpd_fragments_queried"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
if fragments is None:
|
||||||
|
self._mark_task(db, task, "failed", "未获取到小店扭蛋碎片数量")
|
||||||
|
return
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"小店扭蛋碎片: {fragments}",
|
||||||
|
{"fragments": fragments, "role": role, "area_id": int(areaid)},
|
||||||
|
)
|
||||||
|
|
||||||
def _execute_get_bind_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
def _execute_get_bind_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
client = self._client(cookie)
|
client = self._client(cookie)
|
||||||
qr_act_alias = self._bind_qr_act_alias(config)
|
qr_act_alias = self._bind_qr_act_alias(config)
|
||||||
@@ -2584,9 +2664,7 @@ class DouyuBatchRunner:
|
|||||||
if not task or self._stop.is_set():
|
if not task or self._stop.is_set():
|
||||||
return
|
return
|
||||||
account = task.account
|
account = task.account
|
||||||
task.status = "running"
|
self._update_task_progress(worker_db, task, "running", "执行中")
|
||||||
task.message = "执行中"
|
|
||||||
worker_db.commit()
|
|
||||||
|
|
||||||
with self._counter_lock:
|
with self._counter_lock:
|
||||||
self._started += 1
|
self._started += 1
|
||||||
@@ -2631,6 +2709,7 @@ class DouyuBatchRunner:
|
|||||||
"query_xpd_role": self._execute_query_xpd_role,
|
"query_xpd_role": self._execute_query_xpd_role,
|
||||||
"refresh_xpd_goods": self._execute_refresh_xpd_goods,
|
"refresh_xpd_goods": self._execute_refresh_xpd_goods,
|
||||||
"query_xpd_balance": self._execute_query_xpd_balance,
|
"query_xpd_balance": self._execute_query_xpd_balance,
|
||||||
|
"query_xpd_fragments": self._execute_query_xpd_fragments,
|
||||||
}.get(task.task_type)
|
}.get(task.task_type)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
self._mark_task(worker_db, task, "failed", "不支持的任务类型")
|
self._mark_task(worker_db, task, "failed", "不支持的任务类型")
|
||||||
@@ -2669,6 +2748,8 @@ class DouyuBatchRunner:
|
|||||||
task.status = "pending"
|
task.status = "pending"
|
||||||
task.message = "等待执行"
|
task.message = "等待执行"
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
for task in tasks:
|
||||||
|
self._push_task_event(task)
|
||||||
|
|
||||||
total = len(tasks)
|
total = len(tasks)
|
||||||
with ThreadPoolExecutor(max_workers=self.concurrency) as executor:
|
with ThreadPoolExecutor(max_workers=self.concurrency) as executor:
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ from core.douyu.cookie_utils import cookie_value
|
|||||||
from ..models import Account, DouyuConfig, DouyuTask, LoginTask
|
from ..models import Account, DouyuConfig, DouyuTask, LoginTask
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_DOUYU_TASK_TYPES = {
|
SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
|
||||||
"get_bind_qr": "获取绑定二维码",
|
|
||||||
"confirm_bind": "确认绑定",
|
"confirm_bind": "确认绑定",
|
||||||
"create_elite_qr": "开通精英宝典30",
|
"create_elite_qr": "开通精英宝典30",
|
||||||
"prepare_esports_bind": "绑定电竞手册角色",
|
"prepare_esports_bind": "绑定电竞手册角色",
|
||||||
@@ -44,11 +43,11 @@ SUPPORTED_DOUYU_TASK_TYPES = {
|
|||||||
"query_xpd_role": "查询小店绑定角色",
|
"query_xpd_role": "查询小店绑定角色",
|
||||||
"refresh_xpd_goods": "刷新小店商品列表",
|
"refresh_xpd_goods": "刷新小店商品列表",
|
||||||
"query_xpd_balance": "查询小店点券余额",
|
"query_xpd_balance": "查询小店点券余额",
|
||||||
|
"query_xpd_fragments": "查询小店扭蛋碎片",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
DOUYU_CONFIG_DEFAULTS = {
|
DOUYU_CONFIG_DEFAULTS = { "manual_id": "G4KA4Qnz4LDp7",
|
||||||
"manual_id": "G4KA4Qnz4LDp7",
|
|
||||||
"rid": "9263298",
|
"rid": "9263298",
|
||||||
"bind_act_alias": "20260120QYOOB",
|
"bind_act_alias": "20260120QYOOB",
|
||||||
"confirm_act_alias": "20260120QYOOB",
|
"confirm_act_alias": "20260120QYOOB",
|
||||||
@@ -223,3 +222,121 @@ def cleanup_orphan_douyu_tasks(
|
|||||||
task.finished_at = now
|
task.finished_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
return len(tasks)
|
return len(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
def slim_douyu_goods(goods: object) -> object:
|
||||||
|
"""保留兑换图片和列表展示需要的商品小字段。"""
|
||||||
|
if not isinstance(goods, dict):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in goods.items()
|
||||||
|
if key in {"commodityId", "commodity_id", "commodityName", "name", "webPic", "pic", "score", "status"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def slim_douyu_limited_goods(goods: object) -> list[dict[str, object]]:
|
||||||
|
"""限兑列表只返回前几个商品名,避免任务列表携带完整原始数组。"""
|
||||||
|
if not isinstance(goods, list):
|
||||||
|
return []
|
||||||
|
result: list[dict[str, object]] = []
|
||||||
|
for item in goods[:5]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
key: value
|
||||||
|
for key, value in item.items()
|
||||||
|
if key in {"commodityId", "commodity_id", "commodityName", "name"}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def strip_douyu_raw_snapshots(value: object) -> object:
|
||||||
|
"""递归移除任务结果中的原始接口快照。"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: strip_douyu_raw_snapshots(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
if key != "raw"
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [strip_douyu_raw_snapshots(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_douyu_task_result(result: dict | None, task_type: str, *, include_detail: bool = False) -> dict | None:
|
||||||
|
"""列表/实时推送接口剥离原始快照与大数组;详情接口保留完整 result。"""
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return result
|
||||||
|
if include_detail:
|
||||||
|
return result
|
||||||
|
|
||||||
|
data = strip_douyu_raw_snapshots(result)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
for key in (
|
||||||
|
"bind_info",
|
||||||
|
"before_bind_info",
|
||||||
|
"bind_candidates",
|
||||||
|
"cooldown_bind_info",
|
||||||
|
"after_bind_info",
|
||||||
|
"activity_bind_snapshot",
|
||||||
|
"activity_bind_info",
|
||||||
|
"points_query",
|
||||||
|
"exchange_balance_query",
|
||||||
|
"query_act_aliases",
|
||||||
|
"records",
|
||||||
|
):
|
||||||
|
data.pop(key, None)
|
||||||
|
|
||||||
|
# 和平小店任务 result 内嵌 role(含 gameOpenId 等),列表接口剥离其原始快照
|
||||||
|
role = data.get("role")
|
||||||
|
if isinstance(role, dict):
|
||||||
|
data["role"] = strip_douyu_raw_snapshots(role)
|
||||||
|
|
||||||
|
goods = data.get("goods")
|
||||||
|
if isinstance(goods, list):
|
||||||
|
data.pop("goods", None)
|
||||||
|
elif isinstance(goods, dict):
|
||||||
|
data["goods"] = slim_douyu_goods(goods)
|
||||||
|
|
||||||
|
if "limited_goods" in data:
|
||||||
|
data["limited_goods"] = slim_douyu_limited_goods(data.get("limited_goods"))
|
||||||
|
|
||||||
|
if task_type not in {"get_bind_qr", "prepare_esports_bind", "get_esports_bind_qr", "get_xpd_bind_qr"}:
|
||||||
|
data.pop("url", None)
|
||||||
|
if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}:
|
||||||
|
data.pop("pay_url", None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def douyu_task_payload(task: DouyuTask, *, include_detail: bool = False) -> dict:
|
||||||
|
"""生成 REST 与 WebSocket 共用的斗鱼任务载荷。"""
|
||||||
|
account = task.account
|
||||||
|
|
||||||
|
def isoformat(value: datetime | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": task.id,
|
||||||
|
"batch_id": task.batch_id,
|
||||||
|
"account_id": task.account_id,
|
||||||
|
"account_username": (account.username if account else "") or "",
|
||||||
|
"account_uid": (account.uid if account else "") or "",
|
||||||
|
"account_nickname": (account.nickname if account else "") or "",
|
||||||
|
"task_type": task.task_type or "",
|
||||||
|
"status": task.status or "",
|
||||||
|
"message": task.message or "",
|
||||||
|
"result": sanitize_douyu_task_result(
|
||||||
|
task.result if isinstance(task.result, dict) else None,
|
||||||
|
task.task_type or "",
|
||||||
|
include_detail=include_detail,
|
||||||
|
),
|
||||||
|
"created_by": task.created_by,
|
||||||
|
"created_at": isoformat(task.created_at),
|
||||||
|
"finished_at": isoformat(task.finished_at),
|
||||||
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export interface DouyuTaskAccountItem {
|
|||||||
xpd_plat_id: number | null;
|
xpd_plat_id: number | null;
|
||||||
xpd_area_id: number | null;
|
xpd_area_id: number | null;
|
||||||
xpd_balance: number | null;
|
xpd_balance: number | null;
|
||||||
|
xpd_fragments: number | null;
|
||||||
xpd_bind_status: string;
|
xpd_bind_status: string;
|
||||||
assigned_to: number | null;
|
assigned_to: number | null;
|
||||||
assigned_username: string | null;
|
assigned_username: string | null;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ConnectOptions {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
onResult?: () => void;
|
onResult?: () => void;
|
||||||
|
onTask?: (task: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_LOGS = 1000;
|
const MAX_LOGS = 1000;
|
||||||
@@ -76,8 +77,13 @@ export function useWebSocketLogs() {
|
|||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data) as RealtimeLog;
|
const msg = JSON.parse(event.data) as RealtimeLog & { task?: Record<string, unknown> };
|
||||||
if (msg.level === 'heartbeat') return;
|
if (msg.level === 'heartbeat') return;
|
||||||
|
if (msg.level === 'task') {
|
||||||
|
// 任务状态即时推送(不进日志),前端直接局部更新
|
||||||
|
if (msg.task) callbacksMapRef.current.get(key)?.onTask?.(msg.task);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.level === 'result') {
|
if (msg.level === 'result') {
|
||||||
callbacksMapRef.current.get(key)?.onResult?.();
|
callbacksMapRef.current.get(key)?.onResult?.();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography,
|
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography, theme,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { TableProps } from 'antd';
|
import type { TableProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -88,8 +88,9 @@ const PEACE_QUICK_ACTIONS = [
|
|||||||
{ key: 'query_xpd_bind_info', icon: <SearchOutlined /> },
|
{ key: 'query_xpd_bind_info', icon: <SearchOutlined /> },
|
||||||
{ key: 'confirm_xpd_bind', icon: <CheckCircleOutlined /> },
|
{ key: 'confirm_xpd_bind', icon: <CheckCircleOutlined /> },
|
||||||
{ key: 'query_xpd_role', icon: <SearchOutlined /> },
|
{ key: 'query_xpd_role', icon: <SearchOutlined /> },
|
||||||
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
|
|
||||||
{ key: 'query_xpd_balance', icon: <SearchOutlined /> },
|
{ key: 'query_xpd_balance', icon: <SearchOutlined /> },
|
||||||
|
{ key: 'query_xpd_fragments', icon: <SearchOutlined /> },
|
||||||
|
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ELITE_TASK_TYPES = new Set([
|
const ELITE_TASK_TYPES = new Set([
|
||||||
@@ -131,6 +132,7 @@ const PEACE_TASK_TYPES = new Set([
|
|||||||
'query_xpd_role',
|
'query_xpd_role',
|
||||||
'refresh_xpd_goods',
|
'refresh_xpd_goods',
|
||||||
'query_xpd_balance',
|
'query_xpd_balance',
|
||||||
|
'query_xpd_fragments',
|
||||||
]);
|
]);
|
||||||
const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
|
const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
|
||||||
const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
|
const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
|
||||||
@@ -314,6 +316,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
? PEACE_QUICK_ACTIONS
|
? PEACE_QUICK_ACTIONS
|
||||||
: (isEsportsHandbook ? ESPORTS_QUICK_ACTIONS : ELITE_QUICK_ACTIONS);
|
: (isEsportsHandbook ? ESPORTS_QUICK_ACTIONS : ELITE_QUICK_ACTIONS);
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
|
const { token } = theme.useToken();
|
||||||
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
|
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
|
||||||
// 工作台已导入账号 ID(localStorage 持久化,默认空白,用户手动导入/移除)
|
// 工作台已导入账号 ID(localStorage 持久化,默认空白,用户手动导入/移除)
|
||||||
const [workbenchIds, setWorkbenchIds] = useState<number[]>(() => {
|
const [workbenchIds, setWorkbenchIds] = useState<number[]>(() => {
|
||||||
@@ -656,9 +659,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
|
|
||||||
const selectedHasRunning = selectedIds.some((id) => runningAccountIds.has(id));
|
const selectedHasRunning = selectedIds.some((id) => runningAccountIds.has(id));
|
||||||
|
|
||||||
// 有活跃任务或 WS 连接时 3 秒刷 tasks;空闲 15 秒轻量刷新
|
// 任务状态由 WS(level=task) 即时推送;轮询仅作兜底(多标签页/断线重连),
|
||||||
|
// 有活跃任务或 WS 连接时 5 秒一次,空闲 30 秒轻量刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const intervalMs = hasActiveTasks || logs.connected || runningBatchIds.size > 0 ? 3000 : 15000;
|
const intervalMs = hasActiveTasks || logs.connected || runningBatchIds.size > 0 ? 5000 : 30000;
|
||||||
const timer = setInterval(loadTasks, intervalMs);
|
const timer = setInterval(loadTasks, intervalMs);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [hasActiveTasks, loadTasks, logs.connected, runningBatchIds]);
|
}, [hasActiveTasks, loadTasks, logs.connected, runningBatchIds]);
|
||||||
@@ -870,6 +874,16 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
});
|
});
|
||||||
void loadData();
|
void loadData();
|
||||||
},
|
},
|
||||||
|
onTask: (rawTask) => {
|
||||||
|
const task = rawTask as unknown as DouyuTaskItem;
|
||||||
|
// 已有任务原位更新保持排序;新任务插入头部(与后端 id desc 一致)
|
||||||
|
setTasks((prev) => {
|
||||||
|
if (prev.some((t) => t.id === task.id)) {
|
||||||
|
return prev.map((t) => (t.id === task.id ? task : t));
|
||||||
|
}
|
||||||
|
return [task, ...prev].slice(0, 100);
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
message.success(`已创建 ${result.count} 个任务`);
|
message.success(`已创建 ${result.count} 个任务`);
|
||||||
setTimeout(() => { void loadTasks(); }, 400);
|
setTimeout(() => { void loadTasks(); }, 400);
|
||||||
@@ -1272,78 +1286,84 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
width: 80,
|
width: 80,
|
||||||
render: (v) => v ?? <Text type="secondary">-</Text>,
|
render: (v) => v ?? <Text type="secondary">-</Text>,
|
||||||
},
|
},
|
||||||
{
|
// 和平小店无鱼翅/限制兑换/换绑时间概念(可无限制换绑),展示扭蛋碎片
|
||||||
title: '鱼翅', dataIndex: 'gold_balance', width: 70,
|
...(isPeaceHandbook
|
||||||
render: (v: number | null) => v ?? <Text type="secondary">-</Text>,
|
? [{
|
||||||
},
|
title: '扭蛋碎片', dataIndex: 'xpd_fragments', width: 90,
|
||||||
{
|
render: (v: number | null) => v ?? <Text type="secondary">-</Text>,
|
||||||
title: '限制兑换', dataIndex: 'exchange_balance', width: 150, ellipsis: true,
|
}] as NonNullable<TableProps<DouyuTaskAccountItem>['columns']>
|
||||||
render: (_, record) => {
|
: [
|
||||||
const latestLimitedTask = latestLimitedGoodsTaskByAccount.get(record.id);
|
{
|
||||||
const text = limitedGoodsTaskText(latestLimitedTask);
|
title: '鱼翅', dataIndex: 'gold_balance', width: 70,
|
||||||
if (!text) return <Text type="secondary">未查</Text>;
|
render: (v: number | null) => v ?? <Text type="secondary">-</Text>,
|
||||||
if (latestLimitedTask?.status !== 'success') {
|
},
|
||||||
return <Text type="secondary">{text}</Text>;
|
{
|
||||||
}
|
title: '限制兑换', dataIndex: 'exchange_balance', width: 150, ellipsis: true,
|
||||||
const count = resultNumber(latestLimitedTask.result, 'limited_count');
|
render: (_, record) => {
|
||||||
if (count === 0 || text === '无限制商品') return <Tag color="green">无限制商品</Tag>;
|
const latestLimitedTask = latestLimitedGoodsTaskByAccount.get(record.id);
|
||||||
return <Text title={text}>{text}</Text>;
|
const text = limitedGoodsTaskText(latestLimitedTask);
|
||||||
},
|
if (!text) return <Text type="secondary">未查</Text>;
|
||||||
},
|
if (latestLimitedTask?.status !== 'success') {
|
||||||
{
|
return <Text type="secondary">{text}</Text>;
|
||||||
title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130,
|
}
|
||||||
render: (_, record) => {
|
const count = resultNumber(latestLimitedTask.result, 'limited_count');
|
||||||
if (isPeaceHandbook) return <Text type="secondary">-</Text>;
|
if (count === 0 || text === '无限制商品') return <Tag color="green">无限制商品</Tag>;
|
||||||
const fromTask = latestChangeWaitByAccount.get(record.id);
|
return <Text title={text}>{text}</Text>;
|
||||||
const canChangeTime = fromTask?.canChangeTime ?? (
|
},
|
||||||
isEsportsHandbook ? record.esports_can_change_time : null
|
},
|
||||||
);
|
{
|
||||||
const esportsStatus = isEsportsHandbook ? esportsRebindStatus(canChangeTime) : null;
|
title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130,
|
||||||
if (esportsStatus) {
|
render: (_, record) => {
|
||||||
return esportsStatus.available
|
const fromTask = latestChangeWaitByAccount.get(record.id);
|
||||||
? <Tag color="success">可换绑</Tag>
|
const canChangeTime = fromTask?.canChangeTime ?? (
|
||||||
: (
|
isEsportsHandbook ? record.esports_can_change_time : null
|
||||||
<Space direction="vertical" size={0}>
|
|
||||||
<Tag color="orange">暂不可换绑</Tag>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>{esportsStatus.text}</Text>
|
|
||||||
</Space>
|
|
||||||
);
|
);
|
||||||
}
|
const esportsStatus = isEsportsHandbook ? esportsRebindStatus(canChangeTime) : null;
|
||||||
const wait = fromTask?.wait ?? (
|
if (esportsStatus) {
|
||||||
isEsportsHandbook ? record.esports_change_role_wait_time : record.change_role_wait_time
|
return esportsStatus.available
|
||||||
);
|
? <Tag color="success">可换绑</Tag>
|
||||||
const text = fromTask?.text || formatWaitSeconds(wait);
|
: (
|
||||||
if (wait == null && !text) {
|
<Space direction="vertical" size={0}>
|
||||||
return <Text type="secondary">未查</Text>;
|
<Tag color="orange">暂不可换绑</Tag>
|
||||||
}
|
<Text type="secondary" style={{ fontSize: 12 }}>{esportsStatus.text}</Text>
|
||||||
if (wait != null && wait <= 0) {
|
</Space>
|
||||||
return <Tag color="success">可换绑</Tag>;
|
);
|
||||||
}
|
}
|
||||||
if (wait != null && wait > 0) {
|
const wait = fromTask?.wait ?? (
|
||||||
return (
|
isEsportsHandbook ? record.esports_change_role_wait_time : record.change_role_wait_time
|
||||||
<Space direction="vertical" size={0}>
|
);
|
||||||
<Tag color="orange">冷却中</Tag>
|
const text = fromTask?.text || formatWaitSeconds(wait);
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>{text || formatWaitSeconds(wait)}</Text>
|
if (wait == null && !text) {
|
||||||
</Space>
|
return <Text type="secondary">未查</Text>;
|
||||||
);
|
}
|
||||||
}
|
if (wait != null && wait <= 0) {
|
||||||
// 接口没返回数字但有文案
|
return <Tag color="success">可换绑</Tag>;
|
||||||
if (text === '可换绑') return <Tag color="success">可换绑</Tag>;
|
}
|
||||||
return <Text type="secondary">{text || '-'}</Text>;
|
if (wait != null && wait > 0) {
|
||||||
},
|
return (
|
||||||
sorter: (a, b) => {
|
<Space direction="vertical" size={0}>
|
||||||
if (isPeaceHandbook) return 0;
|
<Tag color="orange">冷却中</Tag>
|
||||||
const aw = isEsportsHandbook
|
<Text type="secondary" style={{ fontSize: 12 }}>{text || formatWaitSeconds(wait)}</Text>
|
||||||
? latestChangeWaitByAccount.get(a.id)?.canChangeTime ?? a.esports_can_change_time ?? -1
|
</Space>
|
||||||
: latestChangeWaitByAccount.get(a.id)?.wait
|
);
|
||||||
?? (isEsportsHandbook ? a.esports_change_role_wait_time : a.change_role_wait_time) ?? -1;
|
}
|
||||||
const bw = isEsportsHandbook
|
// 接口没返回数字但有文案
|
||||||
? latestChangeWaitByAccount.get(b.id)?.canChangeTime ?? b.esports_can_change_time ?? -1
|
if (text === '可换绑') return <Tag color="success">可换绑</Tag>;
|
||||||
: latestChangeWaitByAccount.get(b.id)?.wait
|
return <Text type="secondary">{text || '-'}</Text>;
|
||||||
?? (isEsportsHandbook ? b.esports_change_role_wait_time : b.change_role_wait_time) ?? -1;
|
},
|
||||||
return aw - bw;
|
sorter: (a, b) => {
|
||||||
},
|
const aw = isEsportsHandbook
|
||||||
},
|
? latestChangeWaitByAccount.get(a.id)?.canChangeTime ?? a.esports_can_change_time ?? -1
|
||||||
|
: latestChangeWaitByAccount.get(a.id)?.wait
|
||||||
|
?? (isEsportsHandbook ? a.esports_change_role_wait_time : a.change_role_wait_time) ?? -1;
|
||||||
|
const bw = isEsportsHandbook
|
||||||
|
? latestChangeWaitByAccount.get(b.id)?.canChangeTime ?? b.esports_can_change_time ?? -1
|
||||||
|
: latestChangeWaitByAccount.get(b.id)?.wait
|
||||||
|
?? (isEsportsHandbook ? b.esports_change_role_wait_time : b.change_role_wait_time) ?? -1;
|
||||||
|
return aw - bw;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as NonNullable<TableProps<DouyuTaskAccountItem>['columns']>),
|
||||||
{
|
{
|
||||||
title: '最近操作', width: 220,
|
title: '最近操作', width: 220,
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
@@ -1453,10 +1473,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
const sectionStyle = {
|
const sectionStyle = {
|
||||||
border: '1px solid #f0f0f0',
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
padding: 8,
|
padding: 8,
|
||||||
background: '#fff',
|
background: token.colorBgContainer,
|
||||||
};
|
};
|
||||||
const sectionTitleStyle = {
|
const sectionTitleStyle = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -1506,6 +1526,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
{renderActionButton('confirm_xpd_bind', 'primary')}
|
{renderActionButton('confirm_xpd_bind', 'primary')}
|
||||||
{renderActionButton('query_xpd_role', 'primary')}
|
{renderActionButton('query_xpd_role', 'primary')}
|
||||||
{renderActionButton('query_xpd_balance', 'primary')}
|
{renderActionButton('query_xpd_balance', 'primary')}
|
||||||
|
{renderActionButton('query_xpd_fragments', 'primary')}
|
||||||
{renderActionButton('refresh_xpd_goods')}
|
{renderActionButton('refresh_xpd_goods')}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -1781,7 +1802,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 6px 4px 2px;
|
padding: 6px 4px 2px;
|
||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid ${token.colorBorderSecondary};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -2247,7 +2268,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
<img
|
<img
|
||||||
src={exchangePreview.url}
|
src={exchangePreview.url}
|
||||||
alt="兑换结果"
|
alt="兑换结果"
|
||||||
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
|
style={{ width: '100%', borderRadius: 8, border: `1px solid ${token.colorBorderSecondary}` }}
|
||||||
/>
|
/>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{exchangePreview.task.account_nickname
|
{exchangePreview.task.account_nickname
|
||||||
|
|||||||
Reference in New Issue
Block a user