diff --git a/core/douyu/activity_client.py b/core/douyu/activity_client.py index 5f43ac9..4d0ec59 100644 --- a/core/douyu/activity_client.py +++ b/core/douyu/activity_client.py @@ -279,6 +279,13 @@ class DouyuActivityClient: ) if can_change_role is None and isinstance(tx_act, dict): can_change_role = self._first_present(tx_act, "canChangeRole", "canChangeBind") + can_change_time = self._first_present( + data, + "canChangeTime", + "can_change_time", + ) + if can_change_time is None and isinstance(tx_act, dict): + can_change_time = self._first_present(tx_act, "canChangeTime", "can_change_time") return { "act_alias": act_alias, "api_version": api_version, @@ -297,6 +304,7 @@ class DouyuActivityClient: "tx_act": tx_act if isinstance(tx_act, dict) else {}, "change_role_wait_time": change_role_wait_time, "can_change_role": can_change_role, + "can_change_time": can_change_time, "raw": payload, } diff --git a/web/backend/migrations/versions/20260728_0012_douyu_esports_can_change_time.py b/web/backend/migrations/versions/20260728_0012_douyu_esports_can_change_time.py new file mode 100644 index 0000000..74626cd --- /dev/null +++ b/web/backend/migrations/versions/20260728_0012_douyu_esports_can_change_time.py @@ -0,0 +1,31 @@ +"""新增电竞手册可换绑时间 + +Revision ID: 20260728_0012 +Revises: 20260728_0011 +Create Date: 2026-07-28 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260728_0012" +down_revision: Union[str, None] = "20260728_0011" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")} + if "esports_can_change_time" not in columns: + op.add_column("accounts", sa.Column("esports_can_change_time", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")} + if "esports_can_change_time" in columns: + op.drop_column("accounts", "esports_can_change_time") diff --git a/web/backend/models.py b/web/backend/models.py index e87d29c..d77d9fd 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -69,6 +69,7 @@ class Account(Base): esports_game_channel = Column(String(128), default="") esports_bind_status = Column(String(32), default="") esports_change_role_wait_time = Column(Integer, nullable=True) + esports_can_change_time = Column(Integer, nullable=True) created_at = Column(DateTime, default=_utcnow) updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index a5f8988..ab9f90a 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -112,6 +112,7 @@ def _account_out(account: Account) -> DouyuTaskAccountOut: esports_game_channel=account.esports_game_channel or "", esports_bind_status=account.esports_bind_status or "", esports_change_role_wait_time=account.esports_change_role_wait_time, + esports_can_change_time=account.esports_can_change_time, assigned_to=account.assigned_to, assigned_username=account.assigned_user.username if account.assigned_user else None, ) diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 71fa5b4..3537746 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -671,6 +671,7 @@ class DouyuTaskAccountOut(BaseModel): esports_game_channel: str = "" esports_bind_status: str = "" esports_change_role_wait_time: Optional[int] = None + esports_can_change_time: Optional[int] = None assigned_to: Optional[int] = None assigned_username: Optional[str] = None diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 9870ce2..070b297 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -174,6 +174,10 @@ class DouyuBatchRunner: """综合 can_change_role 与换绑倒计时判断是否允许换绑。""" if not bind_info: return True + if str(bind_info.get("api_version") or "") == "esports": + can_change_time = cls._to_int(bind_info.get("can_change_time")) + if can_change_time is not None: + return can_change_time <= int(datetime.now(timezone.utc).timestamp()) wait_time = cls._to_int(bind_info.get("change_role_wait_time")) if wait_time is not None and wait_time > 0: return False @@ -247,6 +251,7 @@ class DouyuBatchRunner: "need_bind_act": self._is_truthy_flag(info.get("need_bind_act")), "need_bind_role": self._is_truthy_flag(info.get("need_bind_role")), "change_role_wait_time": self._to_int(info.get("change_role_wait_time")), + "can_change_time": self._to_int(info.get("can_change_time")), "can_change_role": self._can_change_role(info), "bind_info": info, } @@ -266,6 +271,7 @@ class DouyuBatchRunner: f" need_act={1 if snap['need_bind_act'] else 0}" f" need_role={1 if snap['need_bind_role'] else 0}" f" wait={snap['change_role_wait_time'] if snap['change_role_wait_time'] is not None else '-'}" + f" can_change_time={snap['can_change_time'] if snap['can_change_time'] is not None else '-'}" f"{pending_text}" ) @@ -283,6 +289,7 @@ class DouyuBatchRunner: account.esports_game_name = role_name or account.esports_game_name account.esports_game_channel = self._role_channel(bind_info) or account.esports_game_channel account.esports_change_role_wait_time = self._to_int(bind_info.get("change_role_wait_time")) + account.esports_can_change_time = self._to_int(bind_info.get("can_change_time")) account.esports_bind_status = status account.updated_at = datetime.now(timezone.utc) diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index ddc7c8c..7aaea24 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -201,6 +201,7 @@ export interface DouyuTaskAccountItem { esports_game_channel: string; esports_bind_status: string; esports_change_role_wait_time: number | null; + esports_can_change_time: number | null; assigned_to: number | null; assigned_username: string | null; } diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index f5bb656..6f505ca 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -181,6 +181,21 @@ function formatWaitSeconds(seconds: number | null | undefined): string { return `${minutes}分${secs}秒`; } +function esportsRebindStatus(canChangeTime: number | null | undefined): { + available: boolean; + text: string; +} | null { + if (canChangeTime == null || Number.isNaN(Number(canChangeTime))) return null; + const timestamp = Math.floor(Number(canChangeTime)); + if (timestamp <= 0 || timestamp <= Math.floor(Date.now() / 1000)) { + return { available: true, text: '可换绑' }; + } + return { + available: false, + text: new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false }), + }; +} + function resultNumber(result: Record | null | undefined, key: string): number | null { const value = result?.[key]; if (typeof value === 'number' && Number.isFinite(value)) return value; @@ -267,7 +282,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) // 换绑时间:优先最近一次查询/拦截任务结果,回退账号落库字段 const latestChangeWaitByAccount = useMemo(() => { - const map = new Map(); + const map = new Map(); for (const task of tasks) { const taskTypes = isEsportsHandbook ? ['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind'] @@ -275,12 +292,14 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) if (!taskTypes.includes(task.task_type)) continue; if (!['success', 'failed'].includes(task.status)) continue; const wait = resultNumber(task.result, 'change_role_wait_time'); + const canChangeTime = resultNumber(task.result, 'can_change_time'); // get_bind_qr 只有冷却拦截时才有 wait;无 wait 时跳过,避免覆盖 if (!isEsportsHandbook && task.task_type === 'get_bind_qr' && wait == null) continue; const existing = map.get(task.account_id); if (existing && existing.taskId > task.id) continue; - const text = resultText(task.result, 'change_role_wait_text') || formatWaitSeconds(wait); - map.set(task.account_id, { wait, text, taskId: task.id }); + const esportsStatus = isEsportsHandbook ? esportsRebindStatus(canChangeTime) : null; + const text = esportsStatus?.text || resultText(task.result, 'change_role_wait_text') || formatWaitSeconds(wait); + map.set(task.account_id, { wait, canChangeTime, text, taskId: task.id }); } return map; }, [isEsportsHandbook, tasks]); @@ -610,7 +629,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const esportsPlatName = resultText(esportsResult, 'plat_name'); const esportsRoleLine = [esportsPlatName, esportsAreaName].filter(Boolean).join(' / '); const esportsCanChangeRole = Boolean(esportsBindTask && !esportsConfirmRunning); - const esportsChangeWait = resultNumber(esportsResult, 'change_role_wait_time'); + const esportsCanChangeTime = resultNumber(esportsResult, 'can_change_time'); + const esportsRebind = esportsRebindStatus(esportsCanChangeTime); const esportsQrUrl = resultText(esportsLatestQrTask?.result, 'url'); const esportsAccountName = esportsBindTask ? ( @@ -786,6 +806,20 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130, render: (_, record) => { const fromTask = latestChangeWaitByAccount.get(record.id); + const canChangeTime = fromTask?.canChangeTime ?? ( + isEsportsHandbook ? record.esports_can_change_time : null + ); + const esportsStatus = isEsportsHandbook ? esportsRebindStatus(canChangeTime) : null; + if (esportsStatus) { + return esportsStatus.available + ? 可换绑 + : ( + + 暂不可换绑 + {esportsStatus.text} + + ); + } const wait = fromTask?.wait ?? ( isEsportsHandbook ? record.esports_change_role_wait_time : record.change_role_wait_time ); @@ -809,9 +843,13 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) return {text || '-'}; }, sorter: (a, b) => { - const aw = latestChangeWaitByAccount.get(a.id)?.wait + 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 = latestChangeWaitByAccount.get(b.id)?.wait + 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; }, @@ -1391,8 +1429,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) 当前未检测到游戏角色,请先切换角色。 )} - {esportsChangeWait != null && esportsChangeWait > 0 && esportsRoleName ? ( - 换绑冷却剩余:{formatWaitSeconds(esportsChangeWait)} + {esportsRebind && esportsRoleName ? ( + + {esportsRebind.available ? '当前可换绑' : `可换绑时间:${esportsRebind.text}`} + ) : null} {esportsQrUrl ? (